dream.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606
  1. """
  2. Dream:记忆反思操作(Phase 3)
  3. 两阶段执行:
  4. per_trace_reflect → 为每个有新消息的 trace 生成反思摘要,写 cognition_log
  5. cross_trace_integrate → 汇总各 trace 的反思摘要 + 当前记忆文件,
  6. 用 dream_prompt 指导 LLM 更新记忆文件
  7. 对外入口:
  8. run_dream(store, llm_call, memory_config, dream_scope, model=...)
  9. """
  10. from __future__ import annotations
  11. import json
  12. import logging
  13. import os
  14. import re
  15. import tempfile
  16. from dataclasses import dataclass, field
  17. from datetime import datetime
  18. from pathlib import Path
  19. from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
  20. from cyber_agent.core.memory import (
  21. MEMORY_IDENTITY_CONTEXT_KEY,
  22. MemoryConfig,
  23. MemoryPathError,
  24. compute_memory_identity,
  25. format_memory_injection,
  26. load_memory_files,
  27. normalize_memory_relative_path,
  28. resolve_memory_path,
  29. )
  30. from cyber_agent.trace.models import Trace
  31. from cyber_agent.trace.store import FileSystemTraceStore
  32. logger = logging.getLogger(__name__)
  33. # ===== 默认 prompts =====
  34. DEFAULT_REFLECT_PROMPT = """你正在回顾一次 Agent 执行中发生的事情,为你自己(作为长期身份)的记忆做反思。
  35. 请综合下面的执行过程和知识使用情况,回答:
  36. 1. 这次执行中有什么值得记住的经验?(品味、判断、策略)
  37. 2. 哪些知识的评估反映了我的判断需要调整?
  38. 3. 用户的反馈(如果有)说明了什么?
  39. 用简洁的第一人称段落写,不要逐条列点,不要重复执行细节 —— 你在沉淀"这对未来的我意味着什么"。
  40. 只输出反思内容本身,不要任何其它前缀或 markdown 标题。"""
  41. DEFAULT_DREAM_PROMPT = """你正在整理自己的长期记忆。下面是你最近的反思摘要、以及当前各记忆文件的内容。
  42. 请决定哪些文件应该更新、内容怎么改。原则:
  43. - 只更新真正有新见解的文件,没有变化的就不要动
  44. - 在原有内容基础上演进,不是重写;保留仍然有效的旧内容
  45. - 简洁、人类可读的 markdown 格式
  46. - 新增文件必须是 MemoryConfig.files 已声明的路径(否则不会被下次加载)
  47. **严格按以下 JSON 格式输出,不要任何其它文字**:
  48. ```json
  49. {
  50. "updates": [
  51. {"path": "taste.md", "new_content": "完整的新文件内容"},
  52. {"path": "strategy.md", "new_content": "..."}
  53. ],
  54. "reasoning": "你为什么做这些更新(简短)"
  55. }
  56. ```
  57. 如果没有任何文件需要更新,输出 `{"updates": [], "reasoning": "..."}`。"""
  58. # ===== 数据结构 =====
  59. @dataclass(frozen=True)
  60. class DreamScope:
  61. """Dream 可见 Trace 的完整身份边界。
  62. uid 允许为 None,但仍按 None 做精确匹配;不会将其视为
  63. “不过滤”。
  64. """
  65. uid: Optional[str]
  66. agent_type: str
  67. memory_identity: str
  68. def __post_init__(self) -> None:
  69. if not self.agent_type:
  70. raise ValueError("DreamScope.agent_type is required")
  71. if not self.memory_identity:
  72. raise ValueError("DreamScope.memory_identity is required")
  73. def matches(self, trace: Optional[Trace]) -> bool:
  74. if trace is None:
  75. return False
  76. context = trace.context if isinstance(trace.context, dict) else {}
  77. return (
  78. trace.uid == self.uid
  79. and trace.agent_type == self.agent_type
  80. and context.get(MEMORY_IDENTITY_CONTEXT_KEY) == self.memory_identity
  81. )
  82. class DreamScopeError(ValueError):
  83. """DreamScope 与 MemoryConfig 或 Trace 身份不一致。"""
  84. class DreamPlanError(ValueError):
  85. """LLM 产生的记忆更新计划不安全或不完整。"""
  86. @dataclass
  87. class DreamReport:
  88. per_trace_summaries: Dict[str, str] = field(default_factory=dict) # {trace_id: summary}
  89. updated_files: List[str] = field(default_factory=list) # 实际写入的文件路径
  90. consumed_reflection_count: int = 0 # 本次消化了多少条 reflection
  91. reasoning: str = ""
  92. skipped_traces: List[str] = field(default_factory=list)
  93. LLMCall = Callable[..., Awaitable[Dict[str, Any]]]
  94. # ===== Per-trace 反思 =====
  95. async def per_trace_reflect(
  96. store: FileSystemTraceStore,
  97. llm_call: LLMCall,
  98. trace_id: str,
  99. memory_config: MemoryConfig,
  100. dream_scope: DreamScope,
  101. model: str = "gpt-4o-mini",
  102. ) -> Optional[str]:
  103. """为单个 trace 生成反思摘要,写入 cognition_log,更新 reflected_at_sequence。
  104. Returns:
  105. 反思摘要字符串;若 trace 没有新消息或 LLM 返回空,返回 None。
  106. """
  107. trace = await store.get_trace(trace_id)
  108. if not dream_scope.matches(trace):
  109. logger.warning(f"[Dream] trace 超出 DreamScope,拒绝反思: {trace_id}")
  110. return None
  111. start_seq = (trace.reflected_at_sequence or 0) + 1
  112. end_seq = trace.last_sequence
  113. if start_seq > end_seq:
  114. logger.debug(f"[Dream] trace {trace_id} 没有新消息({start_seq} > {end_seq})")
  115. return None
  116. all_msgs = await store.get_trace_messages(trace_id)
  117. new_msgs = [m for m in all_msgs if start_seq <= m.sequence <= end_seq]
  118. if not new_msgs:
  119. logger.debug(f"[Dream] trace {trace_id} 范围内无消息")
  120. return None
  121. log = await store.get_cognition_log(trace_id)
  122. events = log.get("events", log.get("entries", []))
  123. relevant_events = [
  124. e for e in events
  125. if e.get("sequence") is not None
  126. and start_seq <= e["sequence"] <= end_seq
  127. and e.get("type") in ("query", "evaluation", "extraction_pending", "extraction_committed")
  128. ]
  129. user_content = _build_reflect_input(new_msgs, relevant_events)
  130. prompt = memory_config.reflect_prompt or DEFAULT_REFLECT_PROMPT
  131. try:
  132. result = await llm_call(
  133. messages=[
  134. {"role": "system", "content": prompt},
  135. {"role": "user", "content": user_content},
  136. ],
  137. model=model,
  138. tools=None,
  139. temperature=0.5,
  140. )
  141. except Exception as e:
  142. logger.error(f"[Dream] per_trace_reflect LLM 调用失败 {trace_id}: {e}")
  143. return None
  144. summary = (result.get("content") or "").strip()
  145. if not summary:
  146. logger.info(f"[Dream] trace {trace_id} 反思 LLM 返回空,视为无值得记录的内容")
  147. # 仍然更新 reflected_at_sequence,避免下次重复扫描
  148. await store.update_trace(trace_id, reflected_at_sequence=end_seq)
  149. return None
  150. await store.append_cognition_event(
  151. trace_id=trace_id,
  152. event={
  153. "type": "reflection",
  154. "sequence_range": [start_seq, end_seq],
  155. "summary": summary,
  156. },
  157. )
  158. await store.update_trace(trace_id, reflected_at_sequence=end_seq)
  159. logger.info(f"[Dream] trace {trace_id} 反思完成,覆盖 sequence {start_seq}-{end_seq}")
  160. return summary
  161. def _build_reflect_input(messages: List[Any], events: List[Dict[str, Any]]) -> str:
  162. """把消息和事件组织为 LLM 可读的反思输入。"""
  163. parts: List[str] = ["## 执行过程"]
  164. for m in messages:
  165. role = getattr(m, "role", "?")
  166. desc = getattr(m, "description", "") or ""
  167. seq = getattr(m, "sequence", "?")
  168. # 截断,防止单条过长
  169. parts.append(f"[{seq}] {role}: {desc[:500]}")
  170. if events:
  171. parts.append("\n## 知识使用与提取情况(来自 cognition_log)")
  172. for e in events:
  173. etype = e.get("type")
  174. if etype == "query":
  175. parts.append(
  176. f"- [{e.get('sequence')}] query: {e.get('query', '')[:100]} → "
  177. f"source_ids={e.get('source_ids', [])}"
  178. )
  179. elif etype == "evaluation":
  180. parts.append(
  181. f"- evaluation: knowledge_id={e.get('knowledge_id')} "
  182. f"result={e.get('eval_result')}"
  183. )
  184. elif etype == "extraction_pending":
  185. payload = e.get("payload", {})
  186. parts.append(
  187. f"- extraction_pending ({e.get('extraction_id')}): "
  188. f"{payload.get('task', '')[:80]}"
  189. )
  190. elif etype == "extraction_committed":
  191. parts.append(
  192. f"- extraction_committed: extraction={e.get('extraction_id')} "
  193. f"→ knowledge_id={e.get('knowledge_id')}"
  194. )
  195. return "\n".join(parts)
  196. # ===== 跨 trace 整合 =====
  197. async def cross_trace_integrate(
  198. store: FileSystemTraceStore,
  199. llm_call: LLMCall,
  200. memory_config: MemoryConfig,
  201. dream_scope: DreamScope,
  202. model: str = "gpt-4o",
  203. ) -> Tuple[int, List[str], str]:
  204. """汇总各 trace 未消化的 reflection 事件,用 LLM 更新记忆文件。
  205. Returns:
  206. (consumed_reflection_count, updated_file_paths, reasoning)
  207. """
  208. _validate_dream_scope(memory_config, dream_scope)
  209. all_traces = await store.list_traces(limit=1000)
  210. all_traces = [t for t in all_traces if dream_scope.matches(t)]
  211. # 收集所有未消化的 reflection 事件
  212. reflections: List[Tuple[str, Dict[str, Any]]] = [] # [(trace_id, event)]
  213. for t in all_traces:
  214. log = await store.get_cognition_log(t.trace_id)
  215. events = log.get("events", log.get("entries", []))
  216. for e in events:
  217. if e.get("type") == "reflection" and not e.get("consumed_at"):
  218. reflections.append((t.trace_id, e))
  219. if not reflections:
  220. logger.info("[Dream] 没有未消化的 reflection 事件")
  221. return 0, [], ""
  222. # 读当前记忆文件
  223. existing_files = load_memory_files(memory_config)
  224. user_content = _build_dream_input(reflections, existing_files, memory_config)
  225. prompt = memory_config.dream_prompt or DEFAULT_DREAM_PROMPT
  226. try:
  227. result = await llm_call(
  228. messages=[
  229. {"role": "system", "content": prompt},
  230. {"role": "user", "content": user_content},
  231. ],
  232. model=model,
  233. tools=None,
  234. temperature=0.3,
  235. )
  236. except Exception as e:
  237. logger.error(f"[Dream] cross_trace_integrate LLM 调用失败: {e}")
  238. return 0, [], ""
  239. raw = (result.get("content") or "").strip()
  240. plan = _parse_dream_output(raw)
  241. if plan is None:
  242. logger.error(f"[Dream] LLM 输出无法解析为 JSON 计划,原文: {raw[:500]}")
  243. return 0, [], ""
  244. try:
  245. prepared_updates = _prepare_memory_updates(memory_config, plan)
  246. except (DreamPlanError, MemoryPathError) as exc:
  247. logger.error(f"[Dream] 拒绝不安全的记忆更新计划: {exc}")
  248. return 0, [], ""
  249. # LLM 返回后再次检查 Trace 身份,防止在长耗时调用期间边界变化。
  250. for trace_id in {trace_id for trace_id, _ in reflections}:
  251. if not dream_scope.matches(await store.get_trace(trace_id)):
  252. raise DreamScopeError(
  253. f"trace left DreamScope before memory update: {trace_id}"
  254. )
  255. updated_paths = _transactional_write_memory_updates(
  256. memory_config,
  257. prepared_updates,
  258. )
  259. # 只有全部记忆文件原子替换成功后,才标记 reflection 已消化。
  260. consumed_at = datetime.now().isoformat()
  261. reflections_by_trace: Dict[str, List[Dict[str, Any]]] = {}
  262. for trace_id, event in reflections:
  263. reflections_by_trace.setdefault(trace_id, []).append(event)
  264. consumed_count = 0
  265. for trace_id, trace_reflections in reflections_by_trace.items():
  266. consumed_count += await store.mark_reflections_consumed(
  267. trace_id,
  268. trace_reflections,
  269. consumed_at,
  270. )
  271. reasoning = plan.get("reasoning", "")
  272. return consumed_count, updated_paths, reasoning
  273. def _validate_dream_scope(
  274. memory_config: MemoryConfig,
  275. dream_scope: DreamScope,
  276. ) -> None:
  277. expected_identity = compute_memory_identity(memory_config)
  278. if dream_scope.memory_identity != expected_identity:
  279. raise DreamScopeError(
  280. "DreamScope.memory_identity does not match the active MemoryConfig"
  281. )
  282. def _prepare_memory_updates(
  283. memory_config: MemoryConfig,
  284. plan: Dict[str, Any],
  285. ) -> List[Tuple[str, Path, str]]:
  286. updates = plan.get("updates")
  287. if not isinstance(updates, list):
  288. raise DreamPlanError("updates must be a list")
  289. prepared: List[Tuple[str, Path, str]] = []
  290. seen: set[str] = set()
  291. for index, update in enumerate(updates):
  292. if not isinstance(update, dict):
  293. raise DreamPlanError(f"updates[{index}] must be an object")
  294. rel_path = update.get("path")
  295. new_content = update.get("new_content")
  296. if not isinstance(rel_path, str) or not isinstance(new_content, str):
  297. raise DreamPlanError(
  298. f"updates[{index}] requires string path and new_content"
  299. )
  300. normalized = normalize_memory_relative_path(rel_path)
  301. if normalized != rel_path:
  302. raise DreamPlanError(f"path is not normalized: {rel_path!r}")
  303. if normalized in seen:
  304. raise DreamPlanError(f"duplicate update path: {normalized}")
  305. seen.add(normalized)
  306. prepared.append(
  307. (normalized, resolve_memory_path(memory_config, normalized), new_content)
  308. )
  309. return prepared
  310. def _atomic_write_text(target: Path, content: str) -> None:
  311. """在同目录写临时文件、fsync,然后原子替换目标。"""
  312. tmp_path: Optional[Path] = None
  313. try:
  314. with tempfile.NamedTemporaryFile(
  315. mode="w",
  316. encoding="utf-8",
  317. dir=str(target.parent),
  318. prefix=f".{target.name}.",
  319. suffix=".tmp",
  320. delete=False,
  321. ) as tmp:
  322. tmp.write(content)
  323. tmp.flush()
  324. os.fsync(tmp.fileno())
  325. tmp_path = Path(tmp.name)
  326. os.replace(tmp_path, target)
  327. tmp_path = None
  328. try:
  329. directory_fd = os.open(str(target.parent), os.O_RDONLY)
  330. try:
  331. os.fsync(directory_fd)
  332. finally:
  333. os.close(directory_fd)
  334. except OSError:
  335. # 某些文件系统不支持对目录 fsync;文件本身仍已 fsync + replace。
  336. logger.debug(f"[Dream] 目录 fsync 不可用: {target.parent}")
  337. finally:
  338. if tmp_path is not None:
  339. try:
  340. tmp_path.unlink(missing_ok=True)
  341. except OSError:
  342. logger.warning(f"[Dream] 清理临时文件失败: {tmp_path}")
  343. def _stage_text(target: Path, content: str) -> Path:
  344. """Durably stage one replacement next to its target without publishing it."""
  345. target.parent.mkdir(parents=True, exist_ok=True)
  346. with tempfile.NamedTemporaryFile(
  347. mode="w",
  348. encoding="utf-8",
  349. dir=str(target.parent),
  350. prefix=f".{target.name}.",
  351. suffix=".dream-stage",
  352. delete=False,
  353. ) as staged:
  354. staged.write(content)
  355. staged.flush()
  356. os.fsync(staged.fileno())
  357. return Path(staged.name)
  358. def _transactional_write_memory_updates(
  359. memory_config: MemoryConfig,
  360. prepared_updates: List[Tuple[str, Path, str]],
  361. ) -> List[str]:
  362. """Publish a multi-file Dream plan as one recoverable in-process unit.
  363. Filesystems do not provide a native multi-file rename transaction. We
  364. therefore stage every new value first and keep every old value until all
  365. replacements succeed. A later replacement failure rolls already-published
  366. files back before the exception escapes, so reflections remain retryable
  367. without exposing a partially committed plan.
  368. """
  369. staged_updates: List[Tuple[str, Path, str, Path]] = []
  370. old_values: Dict[Path, Optional[str]] = {}
  371. committed: List[Path] = []
  372. try:
  373. for rel_path, target, new_content in prepared_updates:
  374. target.parent.mkdir(parents=True, exist_ok=True)
  375. checked_target = resolve_memory_path(memory_config, rel_path)
  376. if checked_target != target:
  377. raise MemoryPathError(
  378. f"memory target changed before staging: {rel_path}"
  379. )
  380. old_values[target] = (
  381. target.read_text(encoding="utf-8") if target.exists() else None
  382. )
  383. staged_updates.append(
  384. (rel_path, target, new_content, _stage_text(target, new_content))
  385. )
  386. for rel_path, target, new_content, staged_path in staged_updates:
  387. checked_target = resolve_memory_path(memory_config, rel_path)
  388. if checked_target != target:
  389. raise MemoryPathError(
  390. f"memory target changed before commit: {rel_path}"
  391. )
  392. os.replace(staged_path, target)
  393. committed.append(target)
  394. try:
  395. directory_fd = os.open(str(target.parent), os.O_RDONLY)
  396. try:
  397. os.fsync(directory_fd)
  398. finally:
  399. os.close(directory_fd)
  400. except OSError:
  401. logger.debug("[Dream] 目录 fsync 不可用: %s", target.parent)
  402. logger.info(
  403. "[Dream] 已更新记忆文件: %s (%d chars)",
  404. rel_path,
  405. len(new_content),
  406. )
  407. return [rel_path for rel_path, *_ in staged_updates]
  408. except Exception as commit_error:
  409. rollback_errors: List[str] = []
  410. for target in reversed(committed):
  411. try:
  412. old_content = old_values[target]
  413. if old_content is None:
  414. target.unlink(missing_ok=True)
  415. else:
  416. _atomic_write_text(target, old_content)
  417. except Exception as rollback_error: # pragma: no cover - fatal FS failure
  418. rollback_errors.append(f"{target}: {rollback_error}")
  419. if rollback_errors:
  420. raise RuntimeError(
  421. "Dream update failed and rollback was incomplete: "
  422. + "; ".join(rollback_errors)
  423. ) from commit_error
  424. raise
  425. finally:
  426. for _rel_path, _target, _content, staged_path in staged_updates:
  427. try:
  428. staged_path.unlink(missing_ok=True)
  429. except OSError:
  430. logger.warning("[Dream] 清理 staged 文件失败: %s", staged_path)
  431. def _build_dream_input(
  432. reflections: List[Tuple[str, Dict[str, Any]]],
  433. existing_files: List[Tuple[str, str, str]],
  434. memory_config: MemoryConfig,
  435. ) -> str:
  436. """为 dream prompt 准备输入:反思摘要汇总 + 当前记忆文件 + 允许的文件路径。"""
  437. parts: List[str] = ["## 最近的反思摘要\n"]
  438. for trace_id, e in reflections:
  439. seq_range = e.get("sequence_range", [None, None])
  440. parts.append(
  441. f"### trace {trace_id} (messages {seq_range[0]}-{seq_range[1]})\n"
  442. f"{e.get('summary', '')}\n"
  443. )
  444. parts.append("\n## 当前记忆文件\n")
  445. if existing_files:
  446. parts.append(format_memory_injection(existing_files))
  447. else:
  448. parts.append("(暂无记忆文件)")
  449. if memory_config.files:
  450. parts.append("\n## 允许更新/新增的文件路径\n")
  451. for key, purpose in memory_config.files.items():
  452. parts.append(f"- `{key}`" + (f" — {purpose}" if purpose else ""))
  453. return "\n".join(parts)
  454. def _parse_dream_output(raw: str) -> Optional[Dict[str, Any]]:
  455. """解析 LLM 的 JSON 计划输出。容忍 ```json ... ``` 包裹。"""
  456. stripped = raw.strip()
  457. # 去除 markdown 代码块包裹
  458. m = re.match(r"^```(?:json)?\s*(.*?)\s*```$", stripped, re.DOTALL)
  459. if m:
  460. stripped = m.group(1).strip()
  461. try:
  462. data = json.loads(stripped)
  463. except json.JSONDecodeError:
  464. return None
  465. if not isinstance(data, dict) or "updates" not in data:
  466. return None
  467. return data
  468. # ===== 顶层入口 =====
  469. async def run_dream(
  470. store: FileSystemTraceStore,
  471. llm_call: LLMCall,
  472. memory_config: MemoryConfig,
  473. dream_scope: DreamScope,
  474. reflect_model: str = "gpt-4o-mini",
  475. dream_model: str = "gpt-4o",
  476. ) -> DreamReport:
  477. """执行完整的 dream 流程:per_trace_reflect → cross_trace_integrate。
  478. Args:
  479. dream_scope: 必填的 uid + agent_type + memory_identity 精确边界
  480. reflect_model: per-trace 反思用的模型(轻量模型即可)
  481. dream_model: 跨 trace 整合用的模型(需要更强推理能力)
  482. """
  483. report = DreamReport()
  484. if not memory_config.base_path:
  485. logger.warning("[Dream] memory_config.base_path 未配置,跳过")
  486. return report
  487. _validate_dream_scope(memory_config, dream_scope)
  488. # Phase 1: per-trace reflect
  489. all_traces = await store.list_traces(limit=1000)
  490. all_traces = [t for t in all_traces if dream_scope.matches(t)]
  491. for t in all_traces:
  492. if (t.reflected_at_sequence or 0) >= t.last_sequence:
  493. continue
  494. try:
  495. summary = await per_trace_reflect(
  496. store,
  497. llm_call,
  498. t.trace_id,
  499. memory_config,
  500. dream_scope,
  501. model=reflect_model,
  502. )
  503. if summary:
  504. report.per_trace_summaries[t.trace_id] = summary
  505. except Exception as e:
  506. logger.error(f"[Dream] per_trace_reflect 异常 {t.trace_id}: {e}")
  507. report.skipped_traces.append(t.trace_id)
  508. # Phase 2: cross-trace integrate
  509. try:
  510. consumed, updated, reasoning = await cross_trace_integrate(
  511. store, llm_call, memory_config,
  512. dream_scope=dream_scope, model=dream_model,
  513. )
  514. report.consumed_reflection_count = consumed
  515. report.updated_files = updated
  516. report.reasoning = reasoning
  517. except Exception as e:
  518. logger.error(f"[Dream] cross_trace_integrate 异常: {e}")
  519. return report