code.py 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653
  1. #!/usr/bin/env python3
  2. """
  3. s09_memory.py - 记忆系统
  4. 为编码 Agent 提供跨会话持久知识。
  5. 存储:
  6. .memory/
  7. MEMORY.md ← 索引(每条记忆一行,≤200 行)
  8. feedback_tabs.md ← 独立记忆文件(Markdown + YAML frontmatter)
  9. user_profile.md
  10. project_facts.md
  11. agent_loop 中的流程:
  12. 1. 将 MEMORY.md 索引加载到 SYSTEM 提示词(便宜,始终存在)
  13. 2. 按文件名/描述选择相关记忆 → 注入内容
  14. 3. 运行来自 s08 的压缩流水线
  15. 4. 每轮结束后 → 从原始消息中提取新记忆
  16. 5. 定期合并整理(Dream)
  17. 基于 s08(上下文压缩)构建。用法:
  18. python s09_memory/code.py
  19. 需要: pip install anthropic python-dotenv + .env 中配置 ANTHROPIC_API_KEY
  20. """
  21. import os, subprocess, json, time, re
  22. from pathlib import Path
  23. try:
  24. import readline
  25. readline.parse_and_bind('set bind-tty-special-chars off')
  26. except ImportError:
  27. pass
  28. from anthropic import Anthropic
  29. from dotenv import load_dotenv
  30. load_dotenv(override=True)
  31. if os.getenv("ANTHROPIC_BASE_URL"): os.environ.pop("ANTHROPIC_AUTH_TOKEN", None)
  32. WORKDIR = Path.cwd()
  33. MEMORY_DIR = WORKDIR / ".memory"; MEMORY_DIR.mkdir(exist_ok=True)
  34. MEMORY_INDEX = MEMORY_DIR / "MEMORY.md"
  35. SKILLS_DIR = WORKDIR / "skills"
  36. TRANSCRIPT_DIR = WORKDIR / ".transcripts"
  37. TOOL_RESULTS_DIR = WORKDIR / ".task_outputs" / "tool-results"
  38. client = Anthropic(base_url=os.getenv("ANTHROPIC_BASE_URL"))
  39. MODEL = os.environ["MODEL_ID"]
  40. # ═══════════════════════════════════════════════════════════
  41. # 新增于 s09: 记忆系统
  42. # ═══════════════════════════════════════════════════════════
  43. MEMORY_TYPES = ["user", "feedback", "project", "reference"]
  44. def _parse_frontmatter(text: str) -> tuple[dict, str]:
  45. if not text.startswith("---"):
  46. return {}, text
  47. parts = text.split("---", 2)
  48. if len(parts) < 3:
  49. return {}, text
  50. meta = {}
  51. for line in parts[1].strip().splitlines():
  52. if ":" in line:
  53. k, v = line.split(":", 1)
  54. meta[k.strip()] = v.strip().strip('"').strip("'")
  55. return meta, parts[2].strip()
  56. def write_memory_file(name: str, mem_type: str, description: str, body: str):
  57. """写入单个带 YAML frontmatter 的记忆文件。"""
  58. slug = name.lower().replace(" ", "-").replace("/", "-")
  59. filename = f"{slug}.md"
  60. filepath = MEMORY_DIR / filename
  61. filepath.write_text(
  62. f"---\nname: {name}\ndescription: {description}\ntype: {mem_type}\n---\n\n{body}\n"
  63. )
  64. _rebuild_index()
  65. return filepath
  66. def _rebuild_index():
  67. """根据所有记忆文件重建 MEMORY.md 索引。"""
  68. lines = []
  69. for f in sorted(MEMORY_DIR.glob("*.md")):
  70. if f.name == "MEMORY.md":
  71. continue
  72. raw = f.read_text()
  73. meta, body = _parse_frontmatter(raw)
  74. name = meta.get("name", f.stem)
  75. desc = meta.get("description", body.split("\n")[0][:80])
  76. lines.append(f"- [{name}]({f.name}) — {desc}")
  77. MEMORY_INDEX.write_text("\n".join(lines) + "\n" if lines else "")
  78. def read_memory_index() -> str:
  79. """读取每轮都会注入 SYSTEM 的 MEMORY.md 索引。"""
  80. if not MEMORY_INDEX.exists():
  81. return ""
  82. text = MEMORY_INDEX.read_text().strip()
  83. return text if text else ""
  84. def read_memory_file(filename: str) -> str | None:
  85. """读取单个记忆文件的完整内容。"""
  86. path = MEMORY_DIR / filename
  87. if not path.exists():
  88. return None
  89. return path.read_text()
  90. def list_memory_files() -> list[dict]:
  91. """列出所有记忆文件及其元数据。"""
  92. result = []
  93. for f in sorted(MEMORY_DIR.glob("*.md")):
  94. if f.name == "MEMORY.md":
  95. continue
  96. raw = f.read_text()
  97. meta, body = _parse_frontmatter(raw)
  98. result.append({
  99. "filename": f.name,
  100. "name": meta.get("name", f.stem),
  101. "description": meta.get("description", ""),
  102. "type": meta.get("type", "user"),
  103. "body": body,
  104. })
  105. return result
  106. def select_relevant_memories(messages: list, max_items: int = 5) -> list[str]:
  107. """根据最近对话匹配记忆名称和描述,选择相关记忆文件。
  108. 优先使用一次简单的 LLM 调用;失败时回退到关键词匹配。"""
  109. files = list_memory_files()
  110. if not files:
  111. return []
  112. # 收集最近的用户文本作为上下文
  113. recent_texts = []
  114. for msg in reversed(messages):
  115. if msg.get("role") == "user":
  116. content = msg.get("content", "")
  117. if isinstance(content, list):
  118. content = " ".join(
  119. str(getattr(b, "text", "")) for b in content
  120. if getattr(b, "type", None) == "text"
  121. )
  122. if isinstance(content, str):
  123. recent_texts.append(content)
  124. if len(recent_texts) >= 3:
  125. break
  126. recent = " ".join(reversed(recent_texts))[:2000]
  127. if not recent.strip():
  128. return []
  129. # 构建名称 + 描述目录,供 LLM 选择
  130. catalog_lines = []
  131. for i, f in enumerate(files):
  132. catalog_lines.append(f"{i}: {f['name']} — {f['description']}")
  133. catalog = "\n".join(catalog_lines)
  134. prompt = (
  135. "请根据最近对话和下方记忆目录,选择明显相关的记忆索引。"
  136. "只返回 JSON 整数数组,例如 [0, 3]。"
  137. "如果没有相关记忆,返回 []。\n\n"
  138. f"最近对话:\n{recent}\n\n"
  139. f"记忆目录:\n{catalog}"
  140. )
  141. try:
  142. response = client.messages.create(
  143. model=MODEL,
  144. messages=[{"role": "user", "content": prompt}],
  145. max_tokens=200,
  146. )
  147. text = extract_text(response.content).strip()
  148. # 从响应中提取 JSON 数组
  149. match = re.search(r'\[.*?\]', text, re.DOTALL)
  150. if match:
  151. indices = json.loads(match.group())
  152. selected = []
  153. for idx in indices:
  154. if isinstance(idx, int) and 0 <= idx < len(files):
  155. selected.append(files[idx]["filename"])
  156. if len(selected) >= max_items:
  157. break
  158. return selected
  159. except Exception:
  160. pass
  161. # 兜底:基于名称 + 描述做关键词匹配
  162. keywords = [w.lower() for w in recent.split() if len(w) > 3]
  163. selected = []
  164. for f in files:
  165. text = (f["name"] + " " + f["description"]).lower()
  166. if any(kw in text for kw in keywords):
  167. selected.append(f["filename"])
  168. if len(selected) >= max_items:
  169. break
  170. return selected
  171. def load_memories(messages: list) -> str:
  172. """加载相关记忆内容,用于注入上下文。"""
  173. selected_files = select_relevant_memories(messages)
  174. if not selected_files:
  175. return ""
  176. parts = ["<relevant_memories>"]
  177. for filename in selected_files:
  178. content = read_memory_file(filename)
  179. if content:
  180. parts.append(content)
  181. parts.append("</relevant_memories>")
  182. return "\n\n".join(parts)
  183. def extract_memories(messages: list):
  184. """从最近对话中提取新记忆。每轮结束后运行。"""
  185. # 收集最近的对话文本
  186. dialogue_parts = []
  187. for msg in messages[-10:]:
  188. role = msg.get("role", "?")
  189. content = msg.get("content", "")
  190. if isinstance(content, list):
  191. content = " ".join(
  192. str(getattr(b, "text", "")) for b in content
  193. if getattr(b, "type", None) == "text"
  194. )
  195. if isinstance(content, str) and content.strip():
  196. dialogue_parts.append(f"{role}: {content}")
  197. dialogue = "\n".join(dialogue_parts)
  198. if not dialogue.strip():
  199. return
  200. # 检查已有记忆以避免重复
  201. existing = list_memory_files()
  202. existing_desc = "\n".join(f"- {m['name']}: {m['description']}" for m in existing) if existing else "(无)"
  203. prompt = (
  204. "请从这段对话中提取用户偏好、约束或项目事实。\n"
  205. "返回 JSON 数组。每一项格式为:{name, type, description, body}。\n"
  206. "- name: 简短的 kebab-case 标识,例如 'user-preference-tabs'\n"
  207. "- type: 只能是 'user'(用户偏好)、'feedback'(反馈/指导)、"
  208. "'project'(项目事实)、'reference'(外部引用)之一\n"
  209. "- description: 用于索引检索的一行摘要\n"
  210. "- body: markdown 格式的完整细节\n"
  211. "如果没有新内容,或已被现有记忆覆盖,返回 []。\n\n"
  212. f"已有记忆:\n{existing_desc}\n\n"
  213. f"对话:\n{dialogue[:4000]}"
  214. )
  215. try:
  216. response = client.messages.create(
  217. model=MODEL, messages=[{"role": "user", "content": prompt}], max_tokens=800
  218. )
  219. text = extract_text(response.content).strip()
  220. # 从响应中提取 JSON 数组
  221. match = re.search(r'\[.*\]', text, re.DOTALL)
  222. if not match:
  223. return
  224. items = json.loads(match.group())
  225. if not items:
  226. return
  227. count = 0
  228. for mem in items:
  229. name = mem.get("name", f"memory_{int(time.time())}")
  230. mem_type = mem.get("type", "user")
  231. desc = mem.get("description", "")
  232. body = mem.get("body", "")
  233. if desc and body:
  234. write_memory_file(name, mem_type, desc, body)
  235. count += 1
  236. if count:
  237. print(f"\n\033[33m[记忆:已提取 {count} 条新记忆]\033[0m")
  238. except Exception:
  239. pass
  240. CONSOLIDATE_THRESHOLD = 10
  241. def consolidate_memories():
  242. """合并重复或过时的记忆。文件数量达到阈值时触发。"""
  243. files = list_memory_files()
  244. if len(files) < CONSOLIDATE_THRESHOLD:
  245. return
  246. catalog = "\n\n".join(
  247. f"## {f['filename']}\nname: {f['name']}\ndescription: {f['description']}\n{f['body']}"
  248. for f in files
  249. )
  250. prompt = (
  251. "请整理下列记忆文件。规则:\n"
  252. "1. 合并重复记忆\n"
  253. "2. 删除过时或互相矛盾的记忆\n"
  254. "3. 总数控制在 30 条以内\n"
  255. "4. 优先保留重要的用户偏好\n"
  256. "返回 JSON 数组。每一项格式为:{name, type, description, body}。\n\n"
  257. f"{catalog[:16000]}"
  258. )
  259. try:
  260. response = client.messages.create(
  261. model=MODEL, messages=[{"role": "user", "content": prompt}], max_tokens=3000
  262. )
  263. text = extract_text(response.content).strip()
  264. match = re.search(r'\[.*\]', text, re.DOTALL)
  265. if not match:
  266. return
  267. items = json.loads(match.group())
  268. # 删除旧记忆文件(保留 MEMORY.md)
  269. for f in MEMORY_DIR.glob("*.md"):
  270. if f.name != "MEMORY.md":
  271. f.unlink()
  272. for mem in items:
  273. name = mem.get("name", f"memory_{int(time.time())}")
  274. mem_type = mem.get("type", "user")
  275. desc = mem.get("description", "")
  276. body = mem.get("body", "")
  277. if desc and body:
  278. write_memory_file(name, mem_type, desc, body)
  279. print(f"\n\033[33m[记忆:已将 {len(files)} 条合并为 {len(items)} 条]\033[0m")
  280. except Exception:
  281. pass
  282. # 使用记忆索引构建 SYSTEM
  283. def build_system() -> str:
  284. index = read_memory_index()
  285. memories_section = f"\n\n可用记忆:\n{index}" if index else ""
  286. return (
  287. f"你是位于 {WORKDIR}."
  288. f"{memories_section}\n"
  289. "相关记忆会在下方注入。请遵循记忆中的用户偏好。\n"
  290. "当用户说 'remember' 或表达明确偏好时,将其提取为记忆。"
  291. )
  292. SUB_SYSTEM = (
  293. f"你是位于 {WORKDIR}. "
  294. "完成交给你的任务,然后返回简洁摘要。"
  295. "不要继续委派。"
  296. )
  297. # ═══════════════════════════════════════════════════════════
  298. # 来自 s02-s08 (骨架): 基础工具
  299. # ═══════════════════════════════════════════════════════════
  300. def safe_path(p: str) -> Path:
  301. path = (WORKDIR / p).resolve()
  302. if not path.is_relative_to(WORKDIR): raise ValueError(f"路径逃逸出工作区:{p}")
  303. return path
  304. def run_bash(command: str) -> str:
  305. try:
  306. r = subprocess.run(command, shell=True, cwd=WORKDIR, capture_output=True, text=True, timeout=120)
  307. out = (r.stdout + r.stderr).strip()
  308. return out[:50000] if out else "(无输出)"
  309. except subprocess.TimeoutExpired: return "错误:执行超时(120 秒)"
  310. def run_read(path: str, limit: int | None = None) -> str:
  311. try:
  312. lines = safe_path(path).read_text().splitlines()
  313. if limit and limit < len(lines): lines = lines[:limit] + [f"... ({len(lines) - limit} 行更多内容)"]
  314. return "\n".join(lines)
  315. except Exception as e: return f"错误:{e}"
  316. def run_write(path: str, content: str) -> str:
  317. try:
  318. file_path = safe_path(path); file_path.parent.mkdir(parents=True, exist_ok=True)
  319. file_path.write_text(content); return f"已写入 {len(content)} 字节到 {path}"
  320. except Exception as e: return f"错误:{e}"
  321. def run_edit(path: str, old_text: str, new_text: str) -> str:
  322. try:
  323. file_path = safe_path(path)
  324. text = file_path.read_text()
  325. if old_text not in text: return f"错误:在文件中未找到目标文本:{path}"
  326. file_path.write_text(text.replace(old_text, new_text, 1))
  327. return f"已编辑 {path}"
  328. except Exception as e: return f"错误:{e}"
  329. def run_glob(pattern: str) -> str:
  330. import glob as g
  331. try:
  332. results = []
  333. for match in g.glob(pattern, root_dir=WORKDIR):
  334. if (WORKDIR / match).resolve().is_relative_to(WORKDIR):
  335. results.append(match)
  336. return "\n".join(results) if results else "(无匹配)"
  337. except Exception as e: return f"错误:{e}"
  338. def extract_text(content) -> str:
  339. if not isinstance(content, list): return str(content)
  340. return "\n".join(getattr(b, "text", "") for b in content if getattr(b, "type", None) == "text")
  341. # 子 Agent(从 s06-s07 简化而来)
  342. SUB_TOOLS = [
  343. {"name": "bash", "description": "运行一条 shell 命令。",
  344. "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
  345. {"name": "read_file", "description": "读取文件内容。",
  346. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
  347. {"name": "write_file", "description": "向文件写入内容。",
  348. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
  349. ]
  350. SUB_HANDLERS = {"bash": run_bash, "read_file": run_read, "write_file": run_write}
  351. def spawn_subagent(description: str) -> str:
  352. print(f"\n\033[35m[子 Agent 已启动]\033[0m")
  353. messages = [{"role": "user", "content": description}]
  354. for _ in range(30):
  355. response = client.messages.create(model=MODEL, system=SUB_SYSTEM,
  356. messages=messages, tools=SUB_TOOLS, max_tokens=8000)
  357. messages.append({"role": "assistant", "content": response.content})
  358. if response.stop_reason != "tool_use": break
  359. results = []
  360. for block in response.content:
  361. if block.type == "tool_use":
  362. handler = SUB_HANDLERS.get(block.name)
  363. output = handler(**block.input) if handler else f"未知工具:{block.name}"
  364. print(f" \033[90m[sub] {block.name}: {str(output)[:100]}\033[0m")
  365. results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
  366. messages.append({"role": "user", "content": results})
  367. result = extract_text(messages[-1]["content"])
  368. if not result:
  369. for msg in reversed(messages):
  370. if msg["role"] == "assistant":
  371. result = extract_text(msg["content"])
  372. if result: break
  373. if not result: result = "子 Agent 已等待 30 轮仍未给出最终回答,已停止。"
  374. print(f"\033[35m[子 Agent 已完成]\033[0m")
  375. return result
  376. # ═══════════════════════════════════════════════════════════
  377. # 来自 s08(骨架): 压缩流水线
  378. # ═══════════════════════════════════════════════════════════
  379. CONTEXT_LIMIT = 50000; KEEP_RECENT = 3; PERSIST_THRESHOLD = 30000
  380. def estimate_size(msgs): return len(str(msgs))
  381. def _block_type(block):
  382. return block.get("type") if isinstance(block, dict) else getattr(block, "type", None)
  383. def _message_has_tool_use(msg):
  384. if msg.get("role") != "assistant":
  385. return False
  386. content = msg.get("content")
  387. if not isinstance(content, list):
  388. return False
  389. return any(_block_type(block) == "tool_use" for block in content)
  390. def _is_tool_result_message(msg):
  391. if msg.get("role") != "user":
  392. return False
  393. content = msg.get("content")
  394. if not isinstance(content, list):
  395. return False
  396. return any(isinstance(block, dict) and block.get("type") == "tool_result" for block in content)
  397. def snip_compact(msgs, mx=50):
  398. if len(msgs) <= mx: return msgs
  399. head_end, tail_start = 3, len(msgs) - (mx - 3)
  400. if head_end > 0 and _message_has_tool_use(msgs[head_end - 1]):
  401. while head_end < len(msgs) and _is_tool_result_message(msgs[head_end]):
  402. head_end += 1
  403. if (tail_start > 0 and tail_start < len(msgs)
  404. and _is_tool_result_message(msgs[tail_start])
  405. and _message_has_tool_use(msgs[tail_start - 1])):
  406. tail_start -= 1
  407. if head_end >= tail_start:
  408. return msgs
  409. return msgs[:head_end] + [{"role": "user", "content": f"[已省略中间 {tail_start - head_end} 条消息]"}] + msgs[tail_start:]
  410. def collect_tool_results(msgs):
  411. blocks = []
  412. for mi, msg in enumerate(msgs):
  413. if msg.get("role") != "user" or not isinstance(msg.get("content"), list): continue
  414. for bi, block in enumerate(msg["content"]):
  415. if isinstance(block, dict) and block.get("type") == "tool_result": blocks.append((mi, bi, block))
  416. return blocks
  417. def micro_compact(msgs):
  418. tr = collect_tool_results(msgs)
  419. if len(tr) <= KEEP_RECENT: return msgs
  420. for _, _, b in tr[:-KEEP_RECENT]:
  421. if len(b.get("content", "")) > 120: b["content"] = "[早前工具结果已压缩。]"
  422. return msgs
  423. def persist_large(tid, out):
  424. if len(out) <= PERSIST_THRESHOLD: return out
  425. TOOL_RESULTS_DIR.mkdir(parents=True, exist_ok=True)
  426. p = TOOL_RESULTS_DIR / f"{tid}.txt"
  427. if not p.exists(): p.write_text(out)
  428. return f"<persisted-output>\n完整内容:{p}\n预览:\n{out[:2000]}\n</persisted-output>"
  429. def tool_result_budget(msgs, mx=200_000):
  430. last = msgs[-1] if msgs else None
  431. if not last or last.get("role") != "user" or not isinstance(last.get("content"), list): return msgs
  432. blocks = [(i, b) for i, b in enumerate(last["content"]) if isinstance(b, dict) and b.get("type") == "tool_result"]
  433. total = sum(len(str(b.get("content", ""))) for _, b in blocks)
  434. if total <= mx: return msgs
  435. for _, block in sorted(blocks, key=lambda p: len(str(p[1].get("content", ""))), reverse=True):
  436. if total <= mx: break
  437. c = str(block.get("content", ""))
  438. if len(c) <= PERSIST_THRESHOLD: continue
  439. block["content"] = persist_large(block.get("tool_use_id", "?"), c)
  440. total = sum(len(str(b.get("content", ""))) for _, b in blocks)
  441. return msgs
  442. def write_transcript(msgs):
  443. TRANSCRIPT_DIR.mkdir(parents=True, exist_ok=True)
  444. p = TRANSCRIPT_DIR / f"transcript_{int(time.time())}.jsonl"
  445. with p.open("w") as f:
  446. for m in msgs: f.write(json.dumps(m, default=str) + "\n")
  447. return p
  448. def summarize_history(msgs):
  449. conv = json.dumps(msgs, default=str)[:80000]
  450. r = client.messages.create(model=MODEL, messages=[{"role": "user", "content":
  451. "总结这段编码 Agent 对话,以便继续工作。\n"
  452. "保留:1. 当前目标,2. 关键发现,3. 已修改文件,4. 剩余工作,5. 用户约束。\n\n" + conv}],
  453. max_tokens=2000)
  454. return extract_text(r.content).strip()
  455. def compact_history(msgs):
  456. write_transcript(msgs)
  457. summary = summarize_history(msgs)
  458. return [{"role": "user", "content": f"[已压缩]\n\n{summary}"}]
  459. def reactive_compact(msgs):
  460. write_transcript(msgs)
  461. tail_start = max(0, len(msgs) - 5)
  462. if (tail_start > 0 and tail_start < len(msgs)
  463. and _is_tool_result_message(msgs[tail_start])
  464. and _message_has_tool_use(msgs[tail_start - 1])):
  465. tail_start -= 1
  466. summary = summarize_history(msgs[:tail_start])
  467. return [{"role": "user", "content": f"[响应式压缩]\n\n{summary}"}, *msgs[tail_start:]]
  468. # ═══════════════════════════════════════════════════════════
  469. # 工具定义 (骨架 — 减少工具数量以聚焦记忆)
  470. # ═══════════════════════════════════════════════════════════
  471. TOOLS = [
  472. {"name": "bash", "description": "运行一条 shell 命令。",
  473. "input_schema": {"type": "object", "properties": {"command": {"type": "string"}}, "required": ["command"]}},
  474. {"name": "read_file", "description": "读取文件内容。",
  475. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}}, "required": ["path"]}},
  476. {"name": "write_file", "description": "向文件写入内容。",
  477. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "content": {"type": "string"}}, "required": ["path", "content"]}},
  478. {"name": "edit_file", "description": "在文件中替换一次完全匹配的文本。",
  479. "input_schema": {"type": "object", "properties": {"path": {"type": "string"}, "old_text": {"type": "string"}, "new_text": {"type": "string"}}, "required": ["path", "old_text", "new_text"]}},
  480. {"name": "glob", "description": "查找匹配 glob 模式的文件。",
  481. "input_schema": {"type": "object", "properties": {"pattern": {"type": "string"}}, "required": ["pattern"]}},
  482. {"name": "task", "description": "启动一个子 Agent 来处理子任务。",
  483. "input_schema": {"type": "object", "properties": {"description": {"type": "string"}}, "required": ["description"]}},
  484. ]
  485. TOOL_HANDLERS = {
  486. "bash": run_bash, "read_file": run_read, "write_file": run_write,
  487. "edit_file": run_edit, "glob": run_glob, "task": spawn_subagent,
  488. }
  489. # ═══════════════════════════════════════════════════════════
  490. # agent_loop — s09: 注入记忆,并在每轮后提取
  491. # ═══════════════════════════════════════════════════════════
  492. MAX_REACTIVE_RETRIES = 1
  493. def agent_loop(messages: list):
  494. reactive_retries = 0
  495. # s09: 把相关记忆内容注入当前用户轮次
  496. memories_content = load_memories(messages)
  497. memory_turn = len(messages) - 1 if messages and isinstance(messages[-1].get("content"), str) else None
  498. # s09: 每个用户轮次构建一次系统提示词;循环返回后再更新记忆
  499. system = build_system()
  500. while True:
  501. # s09: 保存压缩前快照,以便准确提取记忆
  502. pre_compress = [m if isinstance(m, dict) else {"role": m.get("role",""),
  503. "content": str(m.get("content",""))} for m in messages]
  504. # s08: 压缩流水线 (budget → snip → micro)
  505. messages[:] = tool_result_budget(messages)
  506. messages[:] = snip_compact(messages)
  507. messages[:] = micro_compact(messages)
  508. if estimate_size(messages) > CONTEXT_LIMIT:
  509. print("[自动压缩]")
  510. messages[:] = compact_history(messages)
  511. try:
  512. request_messages = messages
  513. if memories_content and memory_turn is not None and memory_turn < len(messages):
  514. request_messages = messages.copy()
  515. request_messages[memory_turn] = {
  516. **messages[memory_turn],
  517. "content": memories_content + "\n\n" + messages[memory_turn]["content"],
  518. }
  519. response = client.messages.create(
  520. model=MODEL, system=system, messages=request_messages, tools=TOOLS, max_tokens=8000
  521. )
  522. reactive_retries = 0
  523. except Exception as e:
  524. if ("prompt_too_long" in str(e).lower() or "token 过多" in str(e).lower()) and reactive_retries < MAX_REACTIVE_RETRIES:
  525. print("[响应式压缩]")
  526. messages[:] = reactive_compact(messages)
  527. reactive_retries += 1
  528. continue
  529. raise
  530. messages.append({"role": "assistant", "content": response.content})
  531. if response.stop_reason != "tool_use":
  532. # s09: 从压缩前快照中提取,保证完整性
  533. extract_memories(pre_compress)
  534. consolidate_memories()
  535. return
  536. results = []
  537. for block in response.content:
  538. if block.type != "tool_use": continue
  539. print(f"\033[36m> {block.name}\033[0m")
  540. handler = TOOL_HANDLERS.get(block.name)
  541. output = handler(**block.input) if handler else f"未知工具:{block.name}"
  542. print(str(output)[:200])
  543. results.append({"type": "tool_result", "tool_use_id": block.id, "content": output})
  544. messages.append({"role": "user", "content": results})
  545. if __name__ == "__main__":
  546. print("s09: 记忆 — 持久化跨会话知识")
  547. print("输入问题,回车发送。输入 q 退出。\n")
  548. history = []
  549. while True:
  550. try: query = input("\033[36ms09 >> \033[0m")
  551. except (EOFError, KeyboardInterrupt): break
  552. if query.strip().lower() in ("q", "exit", ""): break
  553. history.append({"role": "user", "content": query})
  554. agent_loop(history)
  555. for block in history[-1]["content"]:
  556. if getattr(block, "type", None) == "text": print(block.text)
  557. print()