memory.py 8.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. """
  2. Memory 系统(Phase 2+)
  3. 详见 cyber_agent/docs/memory.md。核心概念:
  4. - Memory:Agent 身份私有的主观记忆,Markdown 文件,人类可读写
  5. - Dream:记忆反思操作(回顾多个 trace 的执行历史,更新记忆文件)
  6. 本模块只提供 MemoryConfig 数据类和记忆文件加载逻辑。
  7. Dream 操作在 cyber_agent/core/dream.py(Phase 3)。
  8. """
  9. from __future__ import annotations
  10. import glob as _glob
  11. import hashlib
  12. import json
  13. import logging
  14. from dataclasses import dataclass
  15. from fnmatch import fnmatchcase
  16. from pathlib import Path, PurePosixPath
  17. from typing import Dict, List, Optional, Tuple
  18. logger = logging.getLogger(__name__)
  19. MEMORY_IDENTITY_CONTEXT_KEY = "memory_identity"
  20. class MemoryPathError(ValueError):
  21. """记忆配置或 Dream 更新中的路径不安全。"""
  22. @dataclass
  23. class MemoryConfig:
  24. """持久化记忆配置(见 cyber_agent/docs/memory.md 第五节)"""
  25. base_path: str = ""
  26. # 记忆文件根目录。所有文件路径相对此目录解析。
  27. files: Optional[Dict[str, str]] = None
  28. # {路径模式: 用途说明}
  29. # key 支持两种形式:
  30. # - 直接路径:"core/identity.md"
  31. # - glob 模式:"relationships/*.md"、"journals/2026/**.md"
  32. # value 是人类可读的用途说明(注入时作为文件分隔标题的一部分)。
  33. # 框架只负责按 key 解析文件内容;组织结构由配置者决定。
  34. dream_prompt: str = ""
  35. # Dream 跨 trace 整合 prompt;空则使用默认(Phase 3 定义)
  36. reflect_prompt: str = ""
  37. # Per-trace 记忆反思 prompt;空则使用默认(Phase 3 定义)
  38. def _normalize_path_parts(path: str, *, label: str) -> Tuple[str, ...]:
  39. """校验并拆分相对 POSIX 路径。
  40. MemoryConfig.files 是跨平台持久化配置,因此不接受平台特有的
  41. 反斜杠分隔符,也不容忍 ``.``/``..`` 或空路径段的隐式归一化。
  42. """
  43. if not isinstance(path, str) or not path:
  44. raise MemoryPathError(f"{label} must be a non-empty string")
  45. if "\x00" in path or "\\" in path:
  46. raise MemoryPathError(f"{label} contains an invalid separator")
  47. pure = PurePosixPath(path)
  48. if pure.is_absolute():
  49. raise MemoryPathError(f"{label} must be relative")
  50. parts = tuple(path.split("/"))
  51. if any(part in ("", ".", "..") for part in parts):
  52. raise MemoryPathError(f"{label} is not a normalized relative path")
  53. return parts
  54. def normalize_memory_relative_path(path: str) -> str:
  55. """返回经严格校验的 Dream 相对文件路径。"""
  56. return "/".join(_normalize_path_parts(path, label="memory path"))
  57. def _normalize_memory_pattern(pattern: str) -> str:
  58. """返回经严格校验的 MemoryConfig.files 模式。"""
  59. return "/".join(_normalize_path_parts(pattern, label="memory file pattern"))
  60. def _match_pattern_parts(
  61. path_parts: Tuple[str, ...],
  62. pattern_parts: Tuple[str, ...],
  63. path_index: int = 0,
  64. pattern_index: int = 0,
  65. ) -> bool:
  66. """按 glob 语义匹配:``*`` 不跨目录,独立 ``**`` 可跨任意层级。"""
  67. if pattern_index == len(pattern_parts):
  68. return path_index == len(path_parts)
  69. pattern = pattern_parts[pattern_index]
  70. if pattern == "**":
  71. return _match_pattern_parts(
  72. path_parts, pattern_parts, path_index, pattern_index + 1
  73. ) or (
  74. path_index < len(path_parts)
  75. and _match_pattern_parts(
  76. path_parts, pattern_parts, path_index + 1, pattern_index
  77. )
  78. )
  79. return (
  80. path_index < len(path_parts)
  81. and fnmatchcase(path_parts[path_index], pattern)
  82. and _match_pattern_parts(
  83. path_parts, pattern_parts, path_index + 1, pattern_index + 1
  84. )
  85. )
  86. def is_declared_memory_path(config: MemoryConfig, relative_path: str) -> bool:
  87. """判断路径是否被 ``MemoryConfig.files`` 精确授权。"""
  88. if not config.files:
  89. return False
  90. path_parts = _normalize_path_parts(relative_path, label="memory path")
  91. for raw_pattern in config.files:
  92. pattern = _normalize_memory_pattern(raw_pattern)
  93. if _match_pattern_parts(path_parts, tuple(pattern.split("/"))):
  94. return True
  95. return False
  96. def resolve_memory_path(config: MemoryConfig, relative_path: str) -> Path:
  97. """解析已声明的记忆路径,并拒绝符号链接和前缀碰撞逃逸。"""
  98. if not config.base_path:
  99. raise MemoryPathError("memory base_path is not configured")
  100. normalized = normalize_memory_relative_path(relative_path)
  101. if not is_declared_memory_path(config, normalized):
  102. raise MemoryPathError(
  103. f"memory path is not declared by MemoryConfig.files: {normalized}"
  104. )
  105. base = Path(config.base_path).expanduser().resolve(strict=False)
  106. target = (base / normalized).resolve(strict=False)
  107. try:
  108. target.relative_to(base)
  109. except ValueError as exc:
  110. raise MemoryPathError(
  111. f"memory path escapes base_path: {normalized}"
  112. ) from exc
  113. return target
  114. def compute_memory_identity(config: MemoryConfig) -> str:
  115. """由真实 base 目录和允许模式生成稳定的记忆身份。
  116. purpose 和 prompt 只影响展示/反思方式,不改变哪组持久化文件属于该身份。
  117. """
  118. if not config.base_path:
  119. raise ValueError("memory_config.base_path is required")
  120. patterns = sorted(
  121. _normalize_memory_pattern(pattern) for pattern in (config.files or {})
  122. )
  123. canonical = {
  124. "schema_version": 1,
  125. "base_path": str(Path(config.base_path).expanduser().resolve(strict=False)),
  126. "file_patterns": patterns,
  127. }
  128. payload = json.dumps(
  129. canonical, ensure_ascii=False, sort_keys=True, separators=(",", ":")
  130. ).encode("utf-8")
  131. return f"memory-v1:{hashlib.sha256(payload).hexdigest()}"
  132. def load_memory_files(config: MemoryConfig) -> List[Tuple[str, str, str]]:
  133. """按 MemoryConfig.files 的 key 解析磁盘上的记忆文件。
  134. Returns:
  135. List[(relative_path, purpose, content)],按 files 声明顺序扁平化,
  136. 文件不存在则跳过(记 debug 日志),内容为空也保留(方便人类看到占位)。
  137. """
  138. if not config.base_path or not config.files:
  139. return []
  140. base = Path(config.base_path).expanduser()
  141. if not base.exists():
  142. logger.debug(f"[Memory] base_path 不存在: {base}")
  143. return []
  144. base_resolved = base.resolve(strict=False)
  145. results: List[Tuple[str, str, str]] = []
  146. seen: set[str] = set() # 去重(多个 glob 可能命中同一个文件)
  147. for key, purpose in config.files.items():
  148. try:
  149. safe_pattern = _normalize_memory_pattern(key)
  150. except MemoryPathError as exc:
  151. logger.warning(f"[Memory] 忽略不安全的文件模式 {key!r}: {exc}")
  152. continue
  153. # 展开 glob;直接路径也走 glob(无通配符时返回单条或空)
  154. pattern = str(base / safe_pattern)
  155. matched_paths = sorted(_glob.glob(pattern, recursive=True))
  156. if not matched_paths:
  157. # 直接路径没命中时给个 debug(可能还没写第一版)
  158. logger.debug(f"[Memory] {key} 没有匹配文件(尚未创建)")
  159. continue
  160. for fs_path in matched_paths:
  161. candidate = Path(fs_path)
  162. if not candidate.is_file():
  163. continue
  164. resolved = candidate.resolve(strict=False)
  165. try:
  166. rel = resolved.relative_to(base_resolved).as_posix()
  167. except ValueError:
  168. logger.warning(f"[Memory] 拒绝读取 base_path 之外的文件: {fs_path}")
  169. continue
  170. try:
  171. if not is_declared_memory_path(config, rel):
  172. logger.warning(f"[Memory] 拒绝读取未声明的记忆文件: {rel}")
  173. continue
  174. except MemoryPathError as exc:
  175. logger.warning(f"[Memory] 拒绝读取非规范路径 {rel!r}: {exc}")
  176. continue
  177. if rel in seen:
  178. continue
  179. seen.add(rel)
  180. try:
  181. content = resolved.read_text(encoding="utf-8")
  182. except Exception as e:
  183. logger.warning(f"[Memory] 读取失败 {fs_path}: {e}")
  184. continue
  185. results.append((rel, purpose, content))
  186. return results
  187. def format_memory_injection(files: List[Tuple[str, str, str]]) -> str:
  188. """把加载结果格式化为可注入到上下文的 markdown 段。"""
  189. if not files:
  190. return ""
  191. parts = ["## 你的长期记忆\n\n以下是你作为此 Agent 身份积累的记忆(人类可直接编辑):\n"]
  192. for rel, purpose, content in files:
  193. header = f"### `{rel}`"
  194. if purpose:
  195. header += f" — {purpose}"
  196. parts.append(header)
  197. parts.append(content.rstrip() or "_(空文件,尚未积累内容)_")
  198. parts.append("") # 空行分隔
  199. return "\n".join(parts).rstrip() + "\n"