| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606 |
- """
- Dream:记忆反思操作(Phase 3)
- 两阶段执行:
- per_trace_reflect → 为每个有新消息的 trace 生成反思摘要,写 cognition_log
- cross_trace_integrate → 汇总各 trace 的反思摘要 + 当前记忆文件,
- 用 dream_prompt 指导 LLM 更新记忆文件
- 对外入口:
- run_dream(store, llm_call, memory_config, dream_scope, model=...)
- """
- from __future__ import annotations
- import json
- import logging
- import os
- import re
- import tempfile
- from dataclasses import dataclass, field
- from datetime import datetime
- from pathlib import Path
- from typing import Any, Awaitable, Callable, Dict, List, Optional, Tuple
- from cyber_agent.core.memory import (
- MEMORY_IDENTITY_CONTEXT_KEY,
- MemoryConfig,
- MemoryPathError,
- compute_memory_identity,
- format_memory_injection,
- load_memory_files,
- normalize_memory_relative_path,
- resolve_memory_path,
- )
- from cyber_agent.trace.models import Trace
- from cyber_agent.trace.store import FileSystemTraceStore
- logger = logging.getLogger(__name__)
- # ===== 默认 prompts =====
- DEFAULT_REFLECT_PROMPT = """你正在回顾一次 Agent 执行中发生的事情,为你自己(作为长期身份)的记忆做反思。
- 请综合下面的执行过程和知识使用情况,回答:
- 1. 这次执行中有什么值得记住的经验?(品味、判断、策略)
- 2. 哪些知识的评估反映了我的判断需要调整?
- 3. 用户的反馈(如果有)说明了什么?
- 用简洁的第一人称段落写,不要逐条列点,不要重复执行细节 —— 你在沉淀"这对未来的我意味着什么"。
- 只输出反思内容本身,不要任何其它前缀或 markdown 标题。"""
- DEFAULT_DREAM_PROMPT = """你正在整理自己的长期记忆。下面是你最近的反思摘要、以及当前各记忆文件的内容。
- 请决定哪些文件应该更新、内容怎么改。原则:
- - 只更新真正有新见解的文件,没有变化的就不要动
- - 在原有内容基础上演进,不是重写;保留仍然有效的旧内容
- - 简洁、人类可读的 markdown 格式
- - 新增文件必须是 MemoryConfig.files 已声明的路径(否则不会被下次加载)
- **严格按以下 JSON 格式输出,不要任何其它文字**:
- ```json
- {
- "updates": [
- {"path": "taste.md", "new_content": "完整的新文件内容"},
- {"path": "strategy.md", "new_content": "..."}
- ],
- "reasoning": "你为什么做这些更新(简短)"
- }
- ```
- 如果没有任何文件需要更新,输出 `{"updates": [], "reasoning": "..."}`。"""
- # ===== 数据结构 =====
- @dataclass(frozen=True)
- class DreamScope:
- """Dream 可见 Trace 的完整身份边界。
- uid 允许为 None,但仍按 None 做精确匹配;不会将其视为
- “不过滤”。
- """
- uid: Optional[str]
- agent_type: str
- memory_identity: str
- def __post_init__(self) -> None:
- if not self.agent_type:
- raise ValueError("DreamScope.agent_type is required")
- if not self.memory_identity:
- raise ValueError("DreamScope.memory_identity is required")
- def matches(self, trace: Optional[Trace]) -> bool:
- if trace is None:
- return False
- context = trace.context if isinstance(trace.context, dict) else {}
- return (
- trace.uid == self.uid
- and trace.agent_type == self.agent_type
- and context.get(MEMORY_IDENTITY_CONTEXT_KEY) == self.memory_identity
- )
- class DreamScopeError(ValueError):
- """DreamScope 与 MemoryConfig 或 Trace 身份不一致。"""
- class DreamPlanError(ValueError):
- """LLM 产生的记忆更新计划不安全或不完整。"""
- @dataclass
- class DreamReport:
- per_trace_summaries: Dict[str, str] = field(default_factory=dict) # {trace_id: summary}
- updated_files: List[str] = field(default_factory=list) # 实际写入的文件路径
- consumed_reflection_count: int = 0 # 本次消化了多少条 reflection
- reasoning: str = ""
- skipped_traces: List[str] = field(default_factory=list)
- LLMCall = Callable[..., Awaitable[Dict[str, Any]]]
- # ===== Per-trace 反思 =====
- async def per_trace_reflect(
- store: FileSystemTraceStore,
- llm_call: LLMCall,
- trace_id: str,
- memory_config: MemoryConfig,
- dream_scope: DreamScope,
- model: str = "gpt-4o-mini",
- ) -> Optional[str]:
- """为单个 trace 生成反思摘要,写入 cognition_log,更新 reflected_at_sequence。
- Returns:
- 反思摘要字符串;若 trace 没有新消息或 LLM 返回空,返回 None。
- """
- trace = await store.get_trace(trace_id)
- if not dream_scope.matches(trace):
- logger.warning(f"[Dream] trace 超出 DreamScope,拒绝反思: {trace_id}")
- return None
- start_seq = (trace.reflected_at_sequence or 0) + 1
- end_seq = trace.last_sequence
- if start_seq > end_seq:
- logger.debug(f"[Dream] trace {trace_id} 没有新消息({start_seq} > {end_seq})")
- return None
- all_msgs = await store.get_trace_messages(trace_id)
- new_msgs = [m for m in all_msgs if start_seq <= m.sequence <= end_seq]
- if not new_msgs:
- logger.debug(f"[Dream] trace {trace_id} 范围内无消息")
- return None
- log = await store.get_cognition_log(trace_id)
- events = log.get("events", log.get("entries", []))
- relevant_events = [
- e for e in events
- if e.get("sequence") is not None
- and start_seq <= e["sequence"] <= end_seq
- and e.get("type") in ("query", "evaluation", "extraction_pending", "extraction_committed")
- ]
- user_content = _build_reflect_input(new_msgs, relevant_events)
- prompt = memory_config.reflect_prompt or DEFAULT_REFLECT_PROMPT
- try:
- result = await llm_call(
- messages=[
- {"role": "system", "content": prompt},
- {"role": "user", "content": user_content},
- ],
- model=model,
- tools=None,
- temperature=0.5,
- )
- except Exception as e:
- logger.error(f"[Dream] per_trace_reflect LLM 调用失败 {trace_id}: {e}")
- return None
- summary = (result.get("content") or "").strip()
- if not summary:
- logger.info(f"[Dream] trace {trace_id} 反思 LLM 返回空,视为无值得记录的内容")
- # 仍然更新 reflected_at_sequence,避免下次重复扫描
- await store.update_trace(trace_id, reflected_at_sequence=end_seq)
- return None
- await store.append_cognition_event(
- trace_id=trace_id,
- event={
- "type": "reflection",
- "sequence_range": [start_seq, end_seq],
- "summary": summary,
- },
- )
- await store.update_trace(trace_id, reflected_at_sequence=end_seq)
- logger.info(f"[Dream] trace {trace_id} 反思完成,覆盖 sequence {start_seq}-{end_seq}")
- return summary
- def _build_reflect_input(messages: List[Any], events: List[Dict[str, Any]]) -> str:
- """把消息和事件组织为 LLM 可读的反思输入。"""
- parts: List[str] = ["## 执行过程"]
- for m in messages:
- role = getattr(m, "role", "?")
- desc = getattr(m, "description", "") or ""
- seq = getattr(m, "sequence", "?")
- # 截断,防止单条过长
- parts.append(f"[{seq}] {role}: {desc[:500]}")
- if events:
- parts.append("\n## 知识使用与提取情况(来自 cognition_log)")
- for e in events:
- etype = e.get("type")
- if etype == "query":
- parts.append(
- f"- [{e.get('sequence')}] query: {e.get('query', '')[:100]} → "
- f"source_ids={e.get('source_ids', [])}"
- )
- elif etype == "evaluation":
- parts.append(
- f"- evaluation: knowledge_id={e.get('knowledge_id')} "
- f"result={e.get('eval_result')}"
- )
- elif etype == "extraction_pending":
- payload = e.get("payload", {})
- parts.append(
- f"- extraction_pending ({e.get('extraction_id')}): "
- f"{payload.get('task', '')[:80]}"
- )
- elif etype == "extraction_committed":
- parts.append(
- f"- extraction_committed: extraction={e.get('extraction_id')} "
- f"→ knowledge_id={e.get('knowledge_id')}"
- )
- return "\n".join(parts)
- # ===== 跨 trace 整合 =====
- async def cross_trace_integrate(
- store: FileSystemTraceStore,
- llm_call: LLMCall,
- memory_config: MemoryConfig,
- dream_scope: DreamScope,
- model: str = "gpt-4o",
- ) -> Tuple[int, List[str], str]:
- """汇总各 trace 未消化的 reflection 事件,用 LLM 更新记忆文件。
- Returns:
- (consumed_reflection_count, updated_file_paths, reasoning)
- """
- _validate_dream_scope(memory_config, dream_scope)
- all_traces = await store.list_traces(limit=1000)
- all_traces = [t for t in all_traces if dream_scope.matches(t)]
- # 收集所有未消化的 reflection 事件
- reflections: List[Tuple[str, Dict[str, Any]]] = [] # [(trace_id, event)]
- for t in all_traces:
- log = await store.get_cognition_log(t.trace_id)
- events = log.get("events", log.get("entries", []))
- for e in events:
- if e.get("type") == "reflection" and not e.get("consumed_at"):
- reflections.append((t.trace_id, e))
- if not reflections:
- logger.info("[Dream] 没有未消化的 reflection 事件")
- return 0, [], ""
- # 读当前记忆文件
- existing_files = load_memory_files(memory_config)
- user_content = _build_dream_input(reflections, existing_files, memory_config)
- prompt = memory_config.dream_prompt or DEFAULT_DREAM_PROMPT
- try:
- result = await llm_call(
- messages=[
- {"role": "system", "content": prompt},
- {"role": "user", "content": user_content},
- ],
- model=model,
- tools=None,
- temperature=0.3,
- )
- except Exception as e:
- logger.error(f"[Dream] cross_trace_integrate LLM 调用失败: {e}")
- return 0, [], ""
- raw = (result.get("content") or "").strip()
- plan = _parse_dream_output(raw)
- if plan is None:
- logger.error(f"[Dream] LLM 输出无法解析为 JSON 计划,原文: {raw[:500]}")
- return 0, [], ""
- try:
- prepared_updates = _prepare_memory_updates(memory_config, plan)
- except (DreamPlanError, MemoryPathError) as exc:
- logger.error(f"[Dream] 拒绝不安全的记忆更新计划: {exc}")
- return 0, [], ""
- # LLM 返回后再次检查 Trace 身份,防止在长耗时调用期间边界变化。
- for trace_id in {trace_id for trace_id, _ in reflections}:
- if not dream_scope.matches(await store.get_trace(trace_id)):
- raise DreamScopeError(
- f"trace left DreamScope before memory update: {trace_id}"
- )
- updated_paths = _transactional_write_memory_updates(
- memory_config,
- prepared_updates,
- )
- # 只有全部记忆文件原子替换成功后,才标记 reflection 已消化。
- consumed_at = datetime.now().isoformat()
- reflections_by_trace: Dict[str, List[Dict[str, Any]]] = {}
- for trace_id, event in reflections:
- reflections_by_trace.setdefault(trace_id, []).append(event)
- consumed_count = 0
- for trace_id, trace_reflections in reflections_by_trace.items():
- consumed_count += await store.mark_reflections_consumed(
- trace_id,
- trace_reflections,
- consumed_at,
- )
- reasoning = plan.get("reasoning", "")
- return consumed_count, updated_paths, reasoning
- def _validate_dream_scope(
- memory_config: MemoryConfig,
- dream_scope: DreamScope,
- ) -> None:
- expected_identity = compute_memory_identity(memory_config)
- if dream_scope.memory_identity != expected_identity:
- raise DreamScopeError(
- "DreamScope.memory_identity does not match the active MemoryConfig"
- )
- def _prepare_memory_updates(
- memory_config: MemoryConfig,
- plan: Dict[str, Any],
- ) -> List[Tuple[str, Path, str]]:
- updates = plan.get("updates")
- if not isinstance(updates, list):
- raise DreamPlanError("updates must be a list")
- prepared: List[Tuple[str, Path, str]] = []
- seen: set[str] = set()
- for index, update in enumerate(updates):
- if not isinstance(update, dict):
- raise DreamPlanError(f"updates[{index}] must be an object")
- rel_path = update.get("path")
- new_content = update.get("new_content")
- if not isinstance(rel_path, str) or not isinstance(new_content, str):
- raise DreamPlanError(
- f"updates[{index}] requires string path and new_content"
- )
- normalized = normalize_memory_relative_path(rel_path)
- if normalized != rel_path:
- raise DreamPlanError(f"path is not normalized: {rel_path!r}")
- if normalized in seen:
- raise DreamPlanError(f"duplicate update path: {normalized}")
- seen.add(normalized)
- prepared.append(
- (normalized, resolve_memory_path(memory_config, normalized), new_content)
- )
- return prepared
- def _atomic_write_text(target: Path, content: str) -> None:
- """在同目录写临时文件、fsync,然后原子替换目标。"""
- tmp_path: Optional[Path] = None
- try:
- with tempfile.NamedTemporaryFile(
- mode="w",
- encoding="utf-8",
- dir=str(target.parent),
- prefix=f".{target.name}.",
- suffix=".tmp",
- delete=False,
- ) as tmp:
- tmp.write(content)
- tmp.flush()
- os.fsync(tmp.fileno())
- tmp_path = Path(tmp.name)
- os.replace(tmp_path, target)
- tmp_path = None
- try:
- directory_fd = os.open(str(target.parent), os.O_RDONLY)
- try:
- os.fsync(directory_fd)
- finally:
- os.close(directory_fd)
- except OSError:
- # 某些文件系统不支持对目录 fsync;文件本身仍已 fsync + replace。
- logger.debug(f"[Dream] 目录 fsync 不可用: {target.parent}")
- finally:
- if tmp_path is not None:
- try:
- tmp_path.unlink(missing_ok=True)
- except OSError:
- logger.warning(f"[Dream] 清理临时文件失败: {tmp_path}")
- def _stage_text(target: Path, content: str) -> Path:
- """Durably stage one replacement next to its target without publishing it."""
- target.parent.mkdir(parents=True, exist_ok=True)
- with tempfile.NamedTemporaryFile(
- mode="w",
- encoding="utf-8",
- dir=str(target.parent),
- prefix=f".{target.name}.",
- suffix=".dream-stage",
- delete=False,
- ) as staged:
- staged.write(content)
- staged.flush()
- os.fsync(staged.fileno())
- return Path(staged.name)
- def _transactional_write_memory_updates(
- memory_config: MemoryConfig,
- prepared_updates: List[Tuple[str, Path, str]],
- ) -> List[str]:
- """Publish a multi-file Dream plan as one recoverable in-process unit.
- Filesystems do not provide a native multi-file rename transaction. We
- therefore stage every new value first and keep every old value until all
- replacements succeed. A later replacement failure rolls already-published
- files back before the exception escapes, so reflections remain retryable
- without exposing a partially committed plan.
- """
- staged_updates: List[Tuple[str, Path, str, Path]] = []
- old_values: Dict[Path, Optional[str]] = {}
- committed: List[Path] = []
- try:
- for rel_path, target, new_content in prepared_updates:
- target.parent.mkdir(parents=True, exist_ok=True)
- checked_target = resolve_memory_path(memory_config, rel_path)
- if checked_target != target:
- raise MemoryPathError(
- f"memory target changed before staging: {rel_path}"
- )
- old_values[target] = (
- target.read_text(encoding="utf-8") if target.exists() else None
- )
- staged_updates.append(
- (rel_path, target, new_content, _stage_text(target, new_content))
- )
- for rel_path, target, new_content, staged_path in staged_updates:
- checked_target = resolve_memory_path(memory_config, rel_path)
- if checked_target != target:
- raise MemoryPathError(
- f"memory target changed before commit: {rel_path}"
- )
- os.replace(staged_path, target)
- committed.append(target)
- try:
- directory_fd = os.open(str(target.parent), os.O_RDONLY)
- try:
- os.fsync(directory_fd)
- finally:
- os.close(directory_fd)
- except OSError:
- logger.debug("[Dream] 目录 fsync 不可用: %s", target.parent)
- logger.info(
- "[Dream] 已更新记忆文件: %s (%d chars)",
- rel_path,
- len(new_content),
- )
- return [rel_path for rel_path, *_ in staged_updates]
- except Exception as commit_error:
- rollback_errors: List[str] = []
- for target in reversed(committed):
- try:
- old_content = old_values[target]
- if old_content is None:
- target.unlink(missing_ok=True)
- else:
- _atomic_write_text(target, old_content)
- except Exception as rollback_error: # pragma: no cover - fatal FS failure
- rollback_errors.append(f"{target}: {rollback_error}")
- if rollback_errors:
- raise RuntimeError(
- "Dream update failed and rollback was incomplete: "
- + "; ".join(rollback_errors)
- ) from commit_error
- raise
- finally:
- for _rel_path, _target, _content, staged_path in staged_updates:
- try:
- staged_path.unlink(missing_ok=True)
- except OSError:
- logger.warning("[Dream] 清理 staged 文件失败: %s", staged_path)
- def _build_dream_input(
- reflections: List[Tuple[str, Dict[str, Any]]],
- existing_files: List[Tuple[str, str, str]],
- memory_config: MemoryConfig,
- ) -> str:
- """为 dream prompt 准备输入:反思摘要汇总 + 当前记忆文件 + 允许的文件路径。"""
- parts: List[str] = ["## 最近的反思摘要\n"]
- for trace_id, e in reflections:
- seq_range = e.get("sequence_range", [None, None])
- parts.append(
- f"### trace {trace_id} (messages {seq_range[0]}-{seq_range[1]})\n"
- f"{e.get('summary', '')}\n"
- )
- parts.append("\n## 当前记忆文件\n")
- if existing_files:
- parts.append(format_memory_injection(existing_files))
- else:
- parts.append("(暂无记忆文件)")
- if memory_config.files:
- parts.append("\n## 允许更新/新增的文件路径\n")
- for key, purpose in memory_config.files.items():
- parts.append(f"- `{key}`" + (f" — {purpose}" if purpose else ""))
- return "\n".join(parts)
- def _parse_dream_output(raw: str) -> Optional[Dict[str, Any]]:
- """解析 LLM 的 JSON 计划输出。容忍 ```json ... ``` 包裹。"""
- stripped = raw.strip()
- # 去除 markdown 代码块包裹
- m = re.match(r"^```(?:json)?\s*(.*?)\s*```$", stripped, re.DOTALL)
- if m:
- stripped = m.group(1).strip()
- try:
- data = json.loads(stripped)
- except json.JSONDecodeError:
- return None
- if not isinstance(data, dict) or "updates" not in data:
- return None
- return data
- # ===== 顶层入口 =====
- async def run_dream(
- store: FileSystemTraceStore,
- llm_call: LLMCall,
- memory_config: MemoryConfig,
- dream_scope: DreamScope,
- reflect_model: str = "gpt-4o-mini",
- dream_model: str = "gpt-4o",
- ) -> DreamReport:
- """执行完整的 dream 流程:per_trace_reflect → cross_trace_integrate。
- Args:
- dream_scope: 必填的 uid + agent_type + memory_identity 精确边界
- reflect_model: per-trace 反思用的模型(轻量模型即可)
- dream_model: 跨 trace 整合用的模型(需要更强推理能力)
- """
- report = DreamReport()
- if not memory_config.base_path:
- logger.warning("[Dream] memory_config.base_path 未配置,跳过")
- return report
- _validate_dream_scope(memory_config, dream_scope)
- # Phase 1: per-trace reflect
- all_traces = await store.list_traces(limit=1000)
- all_traces = [t for t in all_traces if dream_scope.matches(t)]
- for t in all_traces:
- if (t.reflected_at_sequence or 0) >= t.last_sequence:
- continue
- try:
- summary = await per_trace_reflect(
- store,
- llm_call,
- t.trace_id,
- memory_config,
- dream_scope,
- model=reflect_model,
- )
- if summary:
- report.per_trace_summaries[t.trace_id] = summary
- except Exception as e:
- logger.error(f"[Dream] per_trace_reflect 异常 {t.trace_id}: {e}")
- report.skipped_traces.append(t.trace_id)
- # Phase 2: cross-trace integrate
- try:
- consumed, updated, reasoning = await cross_trace_integrate(
- store, llm_call, memory_config,
- dream_scope=dream_scope, model=dream_model,
- )
- report.consumed_reflection_count = consumed
- report.updated_files = updated
- report.reasoning = reasoning
- except Exception as e:
- logger.error(f"[Dream] cross_trace_integrate 异常: {e}")
- return report
|