store.py 55 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286128712881289129012911292129312941295129612971298129913001301130213031304130513061307130813091310131113121313131413151316131713181319132013211322132313241325132613271328132913301331133213331334133513361337133813391340134113421343134413451346134713481349135013511352135313541355135613571358135913601361136213631364136513661367136813691370137113721373137413751376137713781379138013811382138313841385138613871388138913901391139213931394139513961397139813991400140114021403140414051406140714081409141014111412141314141415141614171418141914201421142214231424142514261427142814291430143114321433143414351436143714381439144014411442144314441445144614471448144914501451145214531454145514561457145814591460146114621463146414651466146714681469147014711472147314741475147614771478147914801481148214831484148514861487148814891490149114921493149414951496149714981499150015011502150315041505150615071508150915101511151215131514
  1. """
  2. 文件系统 Trace 存储实现。
  3. Runner、Trace API 和 Sub-Agent 共享 .trace/ 目录中的血缘、消息与 Goal 数据;
  4. Recursive 资源预算还在根 Trace 目录内维护独立的用量快照。
  5. 目录结构:
  6. .trace/{trace_id}/
  7. ├── meta.json # Trace 元数据
  8. ├── goal.json # GoalTree(扁平 JSON,通过 parent_id 构建层级)
  9. ├── resource_usage.json # Recursive 根 Trace 的树级资源用量
  10. ├── messages/ # Messages(每条独立文件)
  11. │ ├── {message_id}.json
  12. │ └── ...
  13. └── events.jsonl # 事件流(WebSocket 续传)
  14. Sub-Trace 是完全独立的 Trace,有自己的目录:
  15. .trace/{parent_id}@{mode}-{timestamp}-{seq}/
  16. ├── meta.json # parent_trace_id 指向父 Trace
  17. ├── goal.json
  18. ├── messages/
  19. └── events.jsonl
  20. """
  21. import asyncio
  22. import json
  23. import logging
  24. import os
  25. import tempfile
  26. from datetime import datetime
  27. from pathlib import Path
  28. from typing import Dict, List, Optional, Any
  29. from .models import Trace, Message
  30. from .goal_models import GoalTree, Goal, GoalStats
  31. logger = logging.getLogger(__name__)
  32. class TraceStoreCorruptionError(RuntimeError):
  33. """A persisted Trace file exists but cannot be trusted or decoded."""
  34. class _ReentrantAsyncLock:
  35. """Small task-reentrant lock used to serialize one trace's file updates."""
  36. def __init__(self) -> None:
  37. self._lock = asyncio.Lock()
  38. self._owner: Optional[asyncio.Task] = None
  39. self._depth = 0
  40. async def acquire(self) -> None:
  41. task = asyncio.current_task()
  42. if task is not None and self._owner is task:
  43. self._depth += 1
  44. return
  45. await self._lock.acquire()
  46. self._owner = task
  47. self._depth = 1
  48. def release(self) -> None:
  49. task = asyncio.current_task()
  50. if self._owner is not task or self._depth <= 0:
  51. raise RuntimeError("trace lock released by a non-owner task")
  52. self._depth -= 1
  53. if self._depth == 0:
  54. self._owner = None
  55. self._lock.release()
  56. async def __aenter__(self) -> "_ReentrantAsyncLock":
  57. await self.acquire()
  58. return self
  59. async def __aexit__(self, exc_type, exc, tb) -> None:
  60. self.release()
  61. class FileSystemTraceStore:
  62. """按 Trace 分目录持久化数据的 TraceStore 默认实现。
  63. 它实现 protocols.TraceStore,由 Runner、API、Goal 工具和 ResourceBudgetController 共用。"""
  64. def __init__(self, base_path: str = ".trace"):
  65. self.base_path = Path(base_path).resolve()
  66. self.base_path.mkdir(parents=True, exist_ok=True)
  67. self._trace_locks: Dict[str, _ReentrantAsyncLock] = {}
  68. @staticmethod
  69. def _validate_component(value: str, *, field: str) -> str:
  70. """Validate a caller-controlled path component without narrowing valid IDs."""
  71. if not isinstance(value, str) or not value or value in {".", ".."}:
  72. raise ValueError(f"invalid {field}")
  73. if "\x00" in value or "/" in value or "\\" in value:
  74. raise ValueError(f"invalid {field}: path separators are not allowed")
  75. if Path(value).name != value or Path(value).is_absolute():
  76. raise ValueError(f"invalid {field}")
  77. return value
  78. def _lock_for(self, trace_id: str) -> _ReentrantAsyncLock:
  79. trace_id = self._validate_component(trace_id, field="trace_id")
  80. return self._trace_locks.setdefault(trace_id, _ReentrantAsyncLock())
  81. def _require_contained(self, path: Path) -> Path:
  82. resolved = path.resolve(strict=False)
  83. if not resolved.is_relative_to(self.base_path):
  84. raise ValueError(f"trace path escapes base directory: {path}")
  85. return path
  86. @staticmethod
  87. def _fsync_directory(directory: Path) -> None:
  88. try:
  89. fd = os.open(directory, os.O_RDONLY)
  90. except OSError:
  91. return
  92. try:
  93. os.fsync(fd)
  94. finally:
  95. os.close(fd)
  96. def _atomic_write_text(self, path: Path, text: str) -> None:
  97. """Atomically replace one UTF-8 file and durably publish its directory entry."""
  98. self._require_contained(path)
  99. path.parent.mkdir(parents=True, exist_ok=True)
  100. temp_path: Optional[str] = None
  101. try:
  102. with tempfile.NamedTemporaryFile(
  103. mode="w",
  104. encoding="utf-8",
  105. dir=path.parent,
  106. prefix=f".{path.name}.",
  107. suffix=".tmp",
  108. delete=False,
  109. ) as temp_file:
  110. temp_path = temp_file.name
  111. temp_file.write(text)
  112. temp_file.flush()
  113. os.fsync(temp_file.fileno())
  114. os.replace(temp_path, path)
  115. temp_path = None
  116. self._fsync_directory(path.parent)
  117. finally:
  118. if temp_path is not None:
  119. try:
  120. os.unlink(temp_path)
  121. except FileNotFoundError:
  122. pass
  123. def _atomic_write_json(self, path: Path, value: Any) -> None:
  124. self._atomic_write_text(
  125. path,
  126. json.dumps(value, indent=2, ensure_ascii=False),
  127. )
  128. def _atomic_write_bytes(self, path: Path, data: bytes) -> None:
  129. self._require_contained(path)
  130. path.parent.mkdir(parents=True, exist_ok=True)
  131. temp_path: Optional[str] = None
  132. try:
  133. with tempfile.NamedTemporaryFile(
  134. mode="wb",
  135. dir=path.parent,
  136. prefix=f".{path.name}.",
  137. suffix=".tmp",
  138. delete=False,
  139. ) as temp_file:
  140. temp_path = temp_file.name
  141. temp_file.write(data)
  142. temp_file.flush()
  143. os.fsync(temp_file.fileno())
  144. os.replace(temp_path, path)
  145. temp_path = None
  146. self._fsync_directory(path.parent)
  147. finally:
  148. if temp_path is not None:
  149. try:
  150. os.unlink(temp_path)
  151. except FileNotFoundError:
  152. pass
  153. def _get_trace_dir(self, trace_id: str) -> Path:
  154. """获取 trace 目录"""
  155. safe_id = self._validate_component(trace_id, field="trace_id")
  156. return self._require_contained(self.base_path / safe_id)
  157. def _get_meta_file(self, trace_id: str) -> Path:
  158. """获取 meta.json 文件路径"""
  159. return self._get_trace_dir(trace_id) / "meta.json"
  160. def _get_goal_file(self, trace_id: str) -> Path:
  161. """获取 goal.json 文件路径"""
  162. return self._get_trace_dir(trace_id) / "goal.json"
  163. def _get_messages_dir(self, trace_id: str) -> Path:
  164. """获取 messages 目录"""
  165. return self._get_trace_dir(trace_id) / "messages"
  166. def _get_attachments_dir(self, trace_id: str) -> Path:
  167. """Keep binary attachments outside the ``*.json`` message namespace."""
  168. return self._get_trace_dir(trace_id) / "attachments"
  169. def _get_message_file(self, trace_id: str, message_id: str) -> Path:
  170. """获取 message 文件路径"""
  171. safe_id = self._validate_component(message_id, field="message_id")
  172. return self._require_contained(
  173. self._get_messages_dir(trace_id) / f"{safe_id}.json"
  174. )
  175. def _get_events_file(self, trace_id: str) -> Path:
  176. """获取 events.jsonl 文件路径"""
  177. return self._get_trace_dir(trace_id) / "events.jsonl"
  178. def _get_model_usage_file(self, trace_id: str) -> Path:
  179. """获取 model_usage.json 文件路径"""
  180. return self._get_trace_dir(trace_id) / "model_usage.json"
  181. def _get_resource_usage_file(self, root_trace_id: str) -> Path:
  182. """获取根 Trace 的 resource_usage.json 文件路径。"""
  183. return self._get_trace_dir(root_trace_id) / "resource_usage.json"
  184. def _get_candidate_ledger_file(self, root_trace_id: str) -> Path:
  185. return self._get_trace_dir(root_trace_id) / "candidate_ledger.json"
  186. def _get_tool_approvals_file(self, trace_id: str) -> Path:
  187. return self._get_trace_dir(trace_id) / "tool_approvals.json"
  188. # ===== Trace 操作 =====
  189. async def create_trace(self, trace: Trace) -> str:
  190. """创建新的 Trace"""
  191. async with self._lock_for(trace.trace_id):
  192. trace_dir = self._get_trace_dir(trace.trace_id)
  193. trace_dir.mkdir(exist_ok=True)
  194. # 创建 messages 目录
  195. messages_dir = self._get_messages_dir(trace.trace_id)
  196. messages_dir.mkdir(exist_ok=True)
  197. # 写入 meta.json
  198. self._atomic_write_json(
  199. self._get_meta_file(trace.trace_id),
  200. trace.to_dict(),
  201. )
  202. # 创建空的 events.jsonl without truncating a pre-existing journal.
  203. events_file = self._get_events_file(trace.trace_id)
  204. events_file.touch(exist_ok=True)
  205. return trace.trace_id
  206. async def get_trace(self, trace_id: str) -> Optional[Trace]:
  207. """获取 Trace"""
  208. meta_file = self._get_meta_file(trace_id)
  209. if not meta_file.exists():
  210. return None
  211. try:
  212. data = json.loads(meta_file.read_text(encoding="utf-8"))
  213. except (json.JSONDecodeError, OSError, TypeError) as exc:
  214. raise TraceStoreCorruptionError(
  215. f"corrupt trace metadata: {meta_file}"
  216. ) from exc
  217. if not isinstance(data, dict):
  218. raise TraceStoreCorruptionError(
  219. f"trace metadata must be a JSON object: {meta_file}"
  220. )
  221. # 解析 datetime 字段
  222. if data.get("created_at"):
  223. data["created_at"] = datetime.fromisoformat(data["created_at"])
  224. if data.get("completed_at"):
  225. data["completed_at"] = datetime.fromisoformat(data["completed_at"])
  226. return Trace.from_dict(data)
  227. async def update_trace(self, trace_id: str, **updates) -> None:
  228. """更新 Trace"""
  229. async with self._lock_for(trace_id):
  230. trace = await self.get_trace(trace_id)
  231. if not trace:
  232. return
  233. # 更新字段
  234. for key, value in updates.items():
  235. if hasattr(trace, key):
  236. setattr(trace, key, value)
  237. self._atomic_write_json(
  238. self._get_meta_file(trace_id),
  239. trace.to_dict(),
  240. )
  241. async def list_traces(
  242. self,
  243. mode: Optional[str] = None,
  244. agent_type: Optional[str] = None,
  245. uid: Optional[str] = None,
  246. status: Optional[str] = None,
  247. limit: int = 50,
  248. parent_trace_id: Optional[str] = None,
  249. created_by_tool: Optional[str] = None,
  250. ) -> List[Trace]:
  251. """扫描 meta.json 并在截断前完成所有过滤。
  252. Sub-Agent Spawn Guard 依赖父 Trace 和 created_by_tool 条件,避免 Validator 混入六孩子配额。"""
  253. traces = []
  254. if not self.base_path.exists():
  255. return []
  256. for trace_dir in self.base_path.iterdir():
  257. if not trace_dir.is_dir():
  258. continue
  259. try:
  260. self._require_contained(trace_dir)
  261. except ValueError as exc:
  262. raise TraceStoreCorruptionError(
  263. f"trace directory escapes base path: {trace_dir}"
  264. ) from exc
  265. meta_file = trace_dir / "meta.json"
  266. if not meta_file.exists():
  267. continue
  268. try:
  269. data = json.loads(meta_file.read_text(encoding="utf-8"))
  270. if not isinstance(data, dict):
  271. raise TypeError("meta.json must contain an object")
  272. # 过滤
  273. if mode and data.get("mode") != mode:
  274. continue
  275. if agent_type and data.get("agent_type") != agent_type:
  276. continue
  277. if uid and data.get("uid") != uid:
  278. continue
  279. if status and data.get("status") != status:
  280. continue
  281. if parent_trace_id and data.get("parent_trace_id") != parent_trace_id:
  282. continue
  283. if created_by_tool and data.get("context", {}).get("created_by_tool") != created_by_tool:
  284. continue
  285. # 解析 datetime
  286. if data.get("created_at"):
  287. data["created_at"] = datetime.fromisoformat(data["created_at"])
  288. if data.get("completed_at"):
  289. data["completed_at"] = datetime.fromisoformat(data["completed_at"])
  290. traces.append(Trace.from_dict(data))
  291. except Exception as exc:
  292. raise TraceStoreCorruptionError(
  293. f"corrupt trace metadata: {meta_file}"
  294. ) from exc
  295. # 排序(最新的在前)
  296. traces.sort(key=lambda t: t.created_at, reverse=True)
  297. return traces[:limit]
  298. # ===== Recursive 树级资源用量 =====
  299. async def get_resource_usage(
  300. self,
  301. root_trace_id: str,
  302. ) -> Optional[Dict[str, Any]]:
  303. """读取根 Trace 的树级用量快照。
  304. ResourceBudgetController 在进程内锁中调用;文件缺失由上层按 fail closed 处理。"""
  305. usage_file = self._get_resource_usage_file(root_trace_id)
  306. if not usage_file.exists():
  307. return None
  308. try:
  309. data = json.loads(usage_file.read_text(encoding="utf-8"))
  310. if not isinstance(data, dict):
  311. raise TypeError("resource_usage.json must contain an object")
  312. return data
  313. except Exception as exc:
  314. raise TraceStoreCorruptionError(
  315. f"corrupt resource usage: {usage_file}"
  316. ) from exc
  317. async def replace_resource_usage(
  318. self,
  319. root_trace_id: str,
  320. usage: Dict[str, Any],
  321. ) -> None:
  322. """通过同目录临时文件 + os.replace 原子替换用量快照。
  323. ResourceBudgetController 在 Agent/LLM 预留和 Token/成本登记后调用,避免单进程留下半写入文件。"""
  324. async with self._lock_for(root_trace_id):
  325. self._atomic_write_json(
  326. self._get_resource_usage_file(root_trace_id),
  327. usage,
  328. )
  329. # ===== Application candidate ledger =====
  330. async def get_candidate_ledger(
  331. self,
  332. root_trace_id: str,
  333. ) -> Optional[Dict[str, Any]]:
  334. path = self._get_candidate_ledger_file(root_trace_id)
  335. if not path.exists():
  336. return None
  337. try:
  338. raw = json.loads(path.read_text(encoding="utf-8"))
  339. if not isinstance(raw, dict):
  340. raise TypeError("candidate_ledger.json must contain an object")
  341. return raw
  342. except Exception as exc:
  343. raise TraceStoreCorruptionError(
  344. f"corrupt candidate ledger: {path}"
  345. ) from exc
  346. async def replace_candidate_ledger(
  347. self,
  348. root_trace_id: str,
  349. ledger: Dict[str, Any],
  350. ) -> None:
  351. async with self._lock_for(root_trace_id):
  352. if await self.get_trace(root_trace_id) is None:
  353. raise ValueError(f"Root Trace not found: {root_trace_id}")
  354. self._atomic_write_json(
  355. self._get_candidate_ledger_file(root_trace_id),
  356. ledger,
  357. )
  358. # ===== Tool approval state =====
  359. async def get_tool_approval_batch(self, trace_id: str):
  360. from cyber_agent.tools.approval import ToolApprovalBatchV1
  361. path = self._get_tool_approvals_file(trace_id)
  362. if not path.exists():
  363. return None
  364. try:
  365. raw = json.loads(path.read_text(encoding="utf-8"))
  366. return ToolApprovalBatchV1.model_validate(raw)
  367. except Exception as exc:
  368. raise TraceStoreCorruptionError(
  369. f"corrupt tool approval state: {path}"
  370. ) from exc
  371. async def replace_tool_approval_batch(self, trace_id: str, batch) -> None:
  372. async with self._lock_for(trace_id):
  373. self._atomic_write_json(
  374. self._get_tool_approvals_file(trace_id),
  375. batch.model_dump(mode="json"),
  376. )
  377. async def write_message_attachment(
  378. self,
  379. trace_id: str,
  380. message_id: str,
  381. *,
  382. suffix: str,
  383. data: bytes,
  384. ) -> Path:
  385. """Atomically persist an attachment owned by one stored Message."""
  386. self._validate_component(message_id, field="message_id")
  387. if (
  388. not suffix.startswith(".")
  389. or len(suffix) < 2
  390. or len(suffix) > 17
  391. or not suffix[1:].isalnum()
  392. ):
  393. raise ValueError("invalid attachment suffix")
  394. async with self._lock_for(trace_id):
  395. if not self._get_message_file(trace_id, message_id).exists():
  396. raise ValueError(f"message not found for attachment: {message_id}")
  397. path = self._get_attachments_dir(trace_id) / f"{message_id}{suffix}"
  398. self._atomic_write_bytes(path, data)
  399. return path
  400. # ===== GoalTree 操作 =====
  401. async def get_goal_tree(self, trace_id: str) -> Optional[GoalTree]:
  402. """获取 GoalTree"""
  403. goal_file = self._get_goal_file(trace_id)
  404. if not goal_file.exists():
  405. return None
  406. try:
  407. data = json.loads(goal_file.read_text(encoding="utf-8"))
  408. if not isinstance(data, dict):
  409. raise TypeError("goal.json must contain an object")
  410. return GoalTree.from_dict(data)
  411. except Exception as exc:
  412. raise TraceStoreCorruptionError(
  413. f"corrupt goal tree: {goal_file}"
  414. ) from exc
  415. async def update_goal_tree(self, trace_id: str, tree: GoalTree) -> None:
  416. """更新完整 GoalTree"""
  417. async with self._lock_for(trace_id):
  418. self._atomic_write_json(
  419. self._get_goal_file(trace_id),
  420. tree.to_dict(),
  421. )
  422. async def add_goal(self, trace_id: str, goal: Goal) -> None:
  423. """添加 Goal 到 GoalTree"""
  424. async with self._lock_for(trace_id):
  425. tree = await self.get_goal_tree(trace_id)
  426. if not tree:
  427. return
  428. tree.goals.append(goal)
  429. await self.update_goal_tree(trace_id, tree)
  430. # 推送 goal_added 事件
  431. event_data = {
  432. "goal": goal.to_dict(),
  433. "parent_id": goal.parent_id,
  434. }
  435. await self.append_event(trace_id, "goal_added", event_data)
  436. # 打印详细的 goal 信息
  437. desc_preview = goal.description[:80] + "..." if len(goal.description) > 80 else goal.description
  438. print(f"[Goal Added] ID={goal.id}, Parent={goal.parent_id or 'root'}")
  439. print(f" 📝 {desc_preview}")
  440. if goal.reason:
  441. reason_preview = goal.reason[:60] + "..." if len(goal.reason) > 60 else goal.reason
  442. print(f" 💡 {reason_preview}")
  443. async def update_goal(
  444. self,
  445. trace_id: str,
  446. goal_id: str,
  447. *,
  448. cascade_completion: bool = True,
  449. **updates,
  450. ) -> None:
  451. """更新 Goal 并推送 goal_updated 事件。
  452. goal_tool、Sub-Agent 和审核工具调用它;Recursive 传入 cascade_completion=False,防止存储层代替父级完成。"""
  453. async with self._lock_for(trace_id):
  454. await self._update_goal_locked(
  455. trace_id,
  456. goal_id,
  457. cascade_completion=cascade_completion,
  458. **updates,
  459. )
  460. async def _update_goal_locked(
  461. self,
  462. trace_id: str,
  463. goal_id: str,
  464. *,
  465. cascade_completion: bool = True,
  466. **updates,
  467. ) -> None:
  468. tree = await self.get_goal_tree(trace_id)
  469. if not tree:
  470. return
  471. goal = tree.find(goal_id)
  472. if not goal:
  473. return
  474. # 更新字段
  475. for key, value in updates.items():
  476. if hasattr(goal, key):
  477. # 特殊处理 stats 字段(可能是 dict)
  478. if key in ["self_stats", "cumulative_stats"] and isinstance(value, dict):
  479. value = GoalStats.from_dict(value)
  480. setattr(goal, key, value)
  481. await self.update_goal_tree(trace_id, tree)
  482. # 推送 goal_updated 事件
  483. # 如果状态变为 completed,检查是否需要级联完成父 Goal
  484. affected_goals = [{"goal_id": goal_id, "updates": updates}]
  485. if cascade_completion and updates.get("status") == "completed":
  486. # 检查级联完成:如果所有兄弟 Goal 都完成,父 Goal 也完成
  487. cascade_completed = await self._check_cascade_completion(trace_id, goal)
  488. affected_goals.extend(cascade_completed)
  489. await self.append_event(trace_id, "goal_updated", {
  490. "goal_id": goal_id,
  491. "updates": updates,
  492. "affected_goals": affected_goals
  493. })
  494. print(f"[DEBUG] Pushed goal_updated event: goal_id={goal_id}, updates={updates}, affected={len(affected_goals)}")
  495. # Goal 完成时触发知识评估
  496. if updates.get("status") in ["completed", "abandoned"]:
  497. pending = await self.get_pending_knowledge_entries(trace_id)
  498. if pending:
  499. # 在trace.context中设置标志,由runner主循环检查
  500. trace = await self.get_trace(trace_id)
  501. if trace:
  502. if not trace.context:
  503. trace.context = {}
  504. trace.context["pending_knowledge_eval"] = True
  505. trace.context["knowledge_eval_trigger"] = "goal_completion"
  506. await self.update_trace(trace_id, context=trace.context)
  507. logger.info(f"[Knowledge Eval] Goal {goal_id} 完成,设置评估标志,待评估知识: {len(pending)} 条")
  508. async def _check_cascade_completion(
  509. self,
  510. trace_id: str,
  511. completed_goal: Goal
  512. ) -> List[Dict[str, Any]]:
  513. """
  514. Legacy 的 Goal 级联完成:所有子 Goal 都 completed 或 abandoned 时自动完成父 Goal。
  515. update_goal() 只在 cascade_completion=True 时调用;Recursive 审核流明确禁用该路径。
  516. Args:
  517. trace_id: Trace ID
  518. completed_goal: 刚完成的 Goal
  519. Returns:
  520. 受影响的父 Goals 列表(自动完成的)
  521. """
  522. if not completed_goal.parent_id:
  523. return []
  524. tree = await self.get_goal_tree(trace_id)
  525. if not tree:
  526. return []
  527. affected = []
  528. parent = tree.find(completed_goal.parent_id)
  529. if not parent:
  530. return []
  531. # 获取父 Goal 的所有子 Goal
  532. children = tree.get_children(parent.id)
  533. # 检查是否所有子 Goal 都已完成(排除 abandoned)
  534. all_completed = all(
  535. child.status in ["completed", "abandoned"]
  536. for child in children
  537. )
  538. if all_completed and parent.status != "completed":
  539. # 自动完成父 Goal
  540. parent.status = "completed"
  541. if not parent.summary:
  542. # 生成自动摘要
  543. completed_count = sum(1 for c in children if c.status == "completed")
  544. parent.summary = f"所有子目标已完成 ({completed_count}/{len(children)})"
  545. await self.update_goal_tree(trace_id, tree)
  546. affected.append({
  547. "goal_id": parent.id,
  548. "status": "completed",
  549. "summary": parent.summary,
  550. "cumulative_stats": parent.cumulative_stats.to_dict()
  551. })
  552. # 递归检查祖父 Goal
  553. grandparent_affected = await self._check_cascade_completion(trace_id, parent)
  554. affected.extend(grandparent_affected)
  555. return affected
  556. # ===== Message 操作 =====
  557. async def add_message(self, message: Message) -> str:
  558. async with self._lock_for(message.trace_id):
  559. return await self._add_message_locked(message)
  560. async def _add_message_locked(self, message: Message) -> str:
  561. """
  562. 添加 Message
  563. 自动更新关联 Goal 的 stats(self_stats 和祖先的 cumulative_stats)
  564. """
  565. trace_id = message.trace_id
  566. # 1. 写入 message 文件
  567. message_file = self._get_message_file(trace_id, message.message_id)
  568. if message_file.exists():
  569. raise ValueError(
  570. f"message_id already exists in Trace {trace_id}: {message.message_id}"
  571. )
  572. self._atomic_write_json(message_file, message.to_dict())
  573. # 2. 更新 trace 统计
  574. trace = await self.get_trace(trace_id)
  575. if trace:
  576. trace.total_messages += 1
  577. trace.last_sequence = max(trace.last_sequence, message.sequence)
  578. # 累计 tokens(完整版)
  579. if message.prompt_tokens:
  580. trace.total_prompt_tokens += message.prompt_tokens
  581. if message.completion_tokens:
  582. trace.total_completion_tokens += message.completion_tokens
  583. if message.reasoning_tokens:
  584. trace.total_reasoning_tokens += message.reasoning_tokens
  585. if message.cache_creation_tokens:
  586. trace.total_cache_creation_tokens += message.cache_creation_tokens
  587. if message.cache_read_tokens:
  588. trace.total_cache_read_tokens += message.cache_read_tokens
  589. # 向后兼容:也更新 total_tokens
  590. if message.tokens:
  591. trace.total_tokens += message.tokens
  592. elif message.prompt_tokens or message.completion_tokens:
  593. trace.total_tokens += (message.prompt_tokens or 0) + (message.completion_tokens or 0)
  594. if message.cost:
  595. trace.total_cost += message.cost
  596. if message.duration_ms:
  597. trace.total_duration_ms += message.duration_ms
  598. # 更新 Trace
  599. await self.update_trace(
  600. trace_id,
  601. total_messages=trace.total_messages,
  602. last_sequence=trace.last_sequence,
  603. total_tokens=trace.total_tokens,
  604. total_prompt_tokens=trace.total_prompt_tokens,
  605. total_completion_tokens=trace.total_completion_tokens,
  606. total_reasoning_tokens=trace.total_reasoning_tokens,
  607. total_cache_creation_tokens=trace.total_cache_creation_tokens,
  608. total_cache_read_tokens=trace.total_cache_read_tokens,
  609. total_cost=trace.total_cost,
  610. total_duration_ms=trace.total_duration_ms
  611. )
  612. # 3. 更新 Goal stats
  613. await self._update_goal_stats(trace_id, message)
  614. # 4. 追加 message_added 事件
  615. affected_goals = await self._get_affected_goals(trace_id, message)
  616. event_id = await self.append_event(trace_id, "message_added", {
  617. "message": message.to_dict(),
  618. "affected_goals": affected_goals
  619. })
  620. if event_id:
  621. try:
  622. from . import websocket as trace_ws
  623. await trace_ws.broadcast_message_added(
  624. trace_id=trace_id,
  625. event_id=event_id,
  626. message_dict=message.to_dict(),
  627. affected_goals=affected_goals,
  628. )
  629. except Exception:
  630. logger.exception("Failed to broadcast message_added (trace_id=%s, event_id=%s)", trace_id, event_id)
  631. return message.message_id
  632. async def _update_goal_stats(self, trace_id: str, message: Message) -> None:
  633. """更新 Goal 的 self_stats 和祖先的 cumulative_stats"""
  634. tree = await self.get_goal_tree(trace_id)
  635. if not tree:
  636. return
  637. # 找到关联的 Goal
  638. goal = tree.find(message.goal_id)
  639. if not goal:
  640. return
  641. # 更新自身 self_stats
  642. goal.self_stats.message_count += 1
  643. if message.tokens:
  644. goal.self_stats.total_tokens += message.tokens
  645. if message.cost:
  646. goal.self_stats.total_cost += message.cost
  647. # TODO: 更新 preview(工具调用摘要)
  648. # 更新自身 cumulative_stats
  649. goal.cumulative_stats.message_count += 1
  650. if message.tokens:
  651. goal.cumulative_stats.total_tokens += message.tokens
  652. if message.cost:
  653. goal.cumulative_stats.total_cost += message.cost
  654. # 沿祖先链向上更新 cumulative_stats
  655. current_goal = goal
  656. while current_goal.parent_id:
  657. parent = tree.find(current_goal.parent_id)
  658. if not parent:
  659. break
  660. parent.cumulative_stats.message_count += 1
  661. if message.tokens:
  662. parent.cumulative_stats.total_tokens += message.tokens
  663. if message.cost:
  664. parent.cumulative_stats.total_cost += message.cost
  665. current_goal = parent
  666. # 保存更新后的 tree
  667. await self.update_goal_tree(trace_id, tree)
  668. async def _get_affected_goals(self, trace_id: str, message: Message) -> List[Dict[str, Any]]:
  669. """获取受影响的 Goals(自身 + 所有祖先)"""
  670. tree = await self.get_goal_tree(trace_id)
  671. if not tree:
  672. return []
  673. goal = tree.find(message.goal_id)
  674. if not goal:
  675. return []
  676. affected = []
  677. # 添加自身(包含 self_stats 和 cumulative_stats)
  678. affected.append({
  679. "goal_id": goal.id,
  680. "self_stats": goal.self_stats.to_dict(),
  681. "cumulative_stats": goal.cumulative_stats.to_dict()
  682. })
  683. # 添加所有祖先(仅 cumulative_stats)
  684. current_goal = goal
  685. while current_goal.parent_id:
  686. parent = tree.find(current_goal.parent_id)
  687. if not parent:
  688. break
  689. affected.append({
  690. "goal_id": parent.id,
  691. "cumulative_stats": parent.cumulative_stats.to_dict()
  692. })
  693. current_goal = parent
  694. return affected
  695. async def get_message(self, message_id: str) -> Optional[Message]:
  696. """获取 Message(扫描所有 trace)"""
  697. self._validate_component(message_id, field="message_id")
  698. for trace_dir in self.base_path.iterdir():
  699. if not trace_dir.is_dir():
  700. continue
  701. # 检查 messages 目录
  702. try:
  703. message_file = self._get_message_file(trace_dir.name, message_id)
  704. except ValueError as exc:
  705. raise TraceStoreCorruptionError(
  706. f"invalid trace directory in store: {trace_dir}"
  707. ) from exc
  708. if message_file.exists():
  709. try:
  710. data = json.loads(message_file.read_text(encoding="utf-8"))
  711. return Message.from_dict(data)
  712. except Exception as exc:
  713. raise TraceStoreCorruptionError(
  714. f"corrupt message file: {message_file}"
  715. ) from exc
  716. return None
  717. async def get_trace_messages(
  718. self,
  719. trace_id: str,
  720. ) -> List[Message]:
  721. """获取 Trace 的所有 Messages(包含所有分支,按 sequence 排序)"""
  722. messages_dir = self._get_messages_dir(trace_id)
  723. if not messages_dir.exists():
  724. return []
  725. messages = []
  726. for message_file in messages_dir.glob("*.json"):
  727. try:
  728. data = json.loads(message_file.read_text(encoding="utf-8"))
  729. msg = Message.from_dict(data)
  730. messages.append(msg)
  731. except Exception as exc:
  732. raise TraceStoreCorruptionError(
  733. f"corrupt message file: {message_file}"
  734. ) from exc
  735. # 按 sequence 排序
  736. messages.sort(key=lambda m: m.sequence)
  737. return messages
  738. async def get_main_path_messages(
  739. self,
  740. trace_id: str,
  741. head_sequence: int
  742. ) -> List[Message]:
  743. """
  744. 获取从 head_sequence 沿 parent_sequence 链回溯到 root 的完整路径
  745. 此函数是通用的路径追溯函数,返回从指定 head 到 root 的完整消息链。
  746. 只要 trace.head_sequence 管理正确(指向主路径),此函数自然返回主路径消息。
  747. 侧分支消息通过 parent_sequence 链自然被跳过(因为主路径的 parent 不指向侧分支)。
  748. Returns:
  749. 按 sequence 正序排列的路径 Message 列表
  750. """
  751. # 加载所有消息,建立 sequence -> Message 索引
  752. all_messages = await self.get_trace_messages(trace_id)
  753. messages_by_seq = {m.sequence: m for m in all_messages}
  754. # 从 head 沿 parent chain 回溯
  755. path = []
  756. seq = head_sequence
  757. while seq is not None:
  758. msg = messages_by_seq.get(seq)
  759. if not msg:
  760. break
  761. path.append(msg)
  762. seq = msg.parent_sequence
  763. # 反转为正序(root → head)
  764. path.reverse()
  765. return path
  766. async def get_messages_by_goal(
  767. self,
  768. trace_id: str,
  769. goal_id: str
  770. ) -> List[Message]:
  771. """获取指定 Goal 关联的所有 Messages"""
  772. all_messages = await self.get_trace_messages(trace_id)
  773. return [m for m in all_messages if m.goal_id == goal_id]
  774. async def update_message(self, message_id: str, **updates) -> None:
  775. """更新 Message 字段"""
  776. self._validate_component(message_id, field="message_id")
  777. message = await self.get_message(message_id)
  778. if not message:
  779. return
  780. async with self._lock_for(message.trace_id):
  781. # Re-read after taking the trace lock so updates cannot overwrite each other.
  782. message = await self.get_message(message_id)
  783. if not message:
  784. return
  785. for key, value in updates.items():
  786. if hasattr(message, key):
  787. setattr(message, key, value)
  788. self._atomic_write_json(
  789. self._get_message_file(message.trace_id, message_id),
  790. message.to_dict(),
  791. )
  792. async def abandon_messages_after(self, trace_id: str, cutoff_sequence: int) -> List[str]:
  793. """
  794. 将 sequence > cutoff_sequence 的 active messages 标记为 abandoned。
  795. 返回被 abandon 的 message_id 列表。
  796. """
  797. async with self._lock_for(trace_id):
  798. all_messages = await self.get_trace_messages(trace_id)
  799. abandoned_ids = []
  800. now = datetime.now()
  801. for msg in all_messages:
  802. if msg.sequence > cutoff_sequence and msg.status == "active":
  803. msg.status = "abandoned"
  804. msg.abandoned_at = now
  805. self._atomic_write_json(
  806. self._get_message_file(trace_id, msg.message_id),
  807. msg.to_dict(),
  808. )
  809. abandoned_ids.append(msg.message_id)
  810. return abandoned_ids
  811. # ===== 模型使用追踪 =====
  812. async def record_model_usage(
  813. self,
  814. trace_id: str,
  815. sequence: int,
  816. role: str,
  817. model: str,
  818. prompt_tokens: int,
  819. completion_tokens: int,
  820. cache_read_tokens: int = 0,
  821. tool_name: Optional[str] = None,
  822. ) -> None:
  823. """
  824. 记录模型使用情况到 model_usage.json
  825. Args:
  826. trace_id: Trace ID
  827. sequence: 消息序号
  828. role: 角色(assistant/tool)
  829. model: 模型名称
  830. prompt_tokens: 输入tokens
  831. completion_tokens: 输出tokens
  832. cache_read_tokens: 缓存读取tokens
  833. tool_name: 工具名称(role=tool时)
  834. """
  835. async with self._lock_for(trace_id):
  836. await self._record_model_usage_locked(
  837. trace_id=trace_id,
  838. sequence=sequence,
  839. role=role,
  840. model=model,
  841. prompt_tokens=prompt_tokens,
  842. completion_tokens=completion_tokens,
  843. cache_read_tokens=cache_read_tokens,
  844. tool_name=tool_name,
  845. )
  846. async def _record_model_usage_locked(
  847. self,
  848. trace_id: str,
  849. sequence: int,
  850. role: str,
  851. model: str,
  852. prompt_tokens: int,
  853. completion_tokens: int,
  854. cache_read_tokens: int = 0,
  855. tool_name: Optional[str] = None,
  856. ) -> None:
  857. usage_file = self._get_model_usage_file(trace_id)
  858. # 读取现有数据
  859. if usage_file.exists():
  860. try:
  861. data = json.loads(usage_file.read_text(encoding="utf-8"))
  862. if not isinstance(data, dict):
  863. raise TypeError("model_usage.json must contain an object")
  864. except Exception as exc:
  865. raise TraceStoreCorruptionError(
  866. f"corrupt model usage: {usage_file}"
  867. ) from exc
  868. else:
  869. data = {
  870. "summary": {
  871. "total_models": 0,
  872. "total_tokens": 0,
  873. "total_cache_read_tokens": 0,
  874. "agent_tokens": 0,
  875. "tool_tokens": 0,
  876. },
  877. "models": [],
  878. "timeline": [],
  879. }
  880. # 更新summary
  881. total_tokens = prompt_tokens + completion_tokens
  882. data["summary"]["total_tokens"] += total_tokens
  883. data["summary"]["total_cache_read_tokens"] += cache_read_tokens
  884. if role == "assistant":
  885. data["summary"]["agent_tokens"] += total_tokens
  886. source = "agent"
  887. else:
  888. data["summary"]["tool_tokens"] += total_tokens
  889. source = f"tool:{tool_name}" if tool_name else "tool"
  890. # 更新models列表
  891. model_entry = None
  892. for m in data["models"]:
  893. if m["model"] == model and m["source"] == source:
  894. model_entry = m
  895. break
  896. if model_entry:
  897. model_entry["prompt_tokens"] += prompt_tokens
  898. model_entry["completion_tokens"] += completion_tokens
  899. model_entry["total_tokens"] += total_tokens
  900. model_entry["cache_read_tokens"] += cache_read_tokens
  901. model_entry["call_count"] += 1
  902. else:
  903. data["models"].append({
  904. "model": model,
  905. "source": source,
  906. "prompt_tokens": prompt_tokens,
  907. "completion_tokens": completion_tokens,
  908. "total_tokens": total_tokens,
  909. "cache_read_tokens": cache_read_tokens,
  910. "call_count": 1,
  911. })
  912. data["summary"]["total_models"] = len(data["models"])
  913. # 添加到timeline
  914. timeline_entry = {
  915. "sequence": sequence,
  916. "role": role,
  917. "model": model,
  918. "prompt_tokens": prompt_tokens,
  919. "completion_tokens": completion_tokens,
  920. }
  921. if cache_read_tokens > 0:
  922. timeline_entry["cache_read_tokens"] = cache_read_tokens
  923. if tool_name:
  924. timeline_entry["tool_name"] = tool_name
  925. data["timeline"].append(timeline_entry)
  926. # 写回文件
  927. self._atomic_write_json(usage_file, data)
  928. # ===== 事件流操作(用于 WebSocket 断线续传)=====
  929. async def get_events(
  930. self,
  931. trace_id: str,
  932. since_event_id: int = 0
  933. ) -> List[Dict[str, Any]]:
  934. """获取事件流"""
  935. events_file = self._get_events_file(trace_id)
  936. if not events_file.exists():
  937. return []
  938. events = []
  939. previous_id = 0
  940. with events_file.open('r', encoding='utf-8') as f:
  941. for line_number, line in enumerate(f, start=1):
  942. if not line.strip():
  943. continue
  944. try:
  945. event = json.loads(line.strip())
  946. event_id = int(event.get("event_id", 0))
  947. if event_id <= previous_id:
  948. raise ValueError("event IDs are not strictly increasing")
  949. previous_id = event_id
  950. if event_id > since_event_id:
  951. events.append(event)
  952. except Exception as exc:
  953. raise TraceStoreCorruptionError(
  954. f"corrupt event journal {events_file}:{line_number}"
  955. ) from exc
  956. return events
  957. async def append_event(
  958. self,
  959. trace_id: str,
  960. event_type: str,
  961. payload: Dict[str, Any]
  962. ) -> int:
  963. """追加事件,返回 event_id"""
  964. async with self._lock_for(trace_id):
  965. return await self._append_event_locked(trace_id, event_type, payload)
  966. async def _append_event_locked(
  967. self,
  968. trace_id: str,
  969. event_type: str,
  970. payload: Dict[str, Any],
  971. ) -> int:
  972. # 获取 trace 并递增 event_id
  973. trace = await self.get_trace(trace_id)
  974. if not trace:
  975. return 0
  976. trace.last_event_id += 1
  977. event_id = trace.last_event_id
  978. # 更新 trace 的 last_event_id
  979. await self.update_trace(trace_id, last_event_id=event_id)
  980. # 创建事件
  981. event = {
  982. "event_id": event_id,
  983. "event": event_type,
  984. "ts": datetime.now().isoformat(),
  985. **payload
  986. }
  987. # 追加到 events.jsonl
  988. events_file = self._get_events_file(trace_id)
  989. with events_file.open('a', encoding='utf-8') as f:
  990. f.write(json.dumps(event, ensure_ascii=False) + '\n')
  991. f.flush()
  992. os.fsync(f.fileno())
  993. return event_id
  994. async def reconcile_trace_integrity(self, trace_id: str) -> Dict[str, int]:
  995. """Rebuild recoverable Trace counters from durable message/event files."""
  996. async with self._lock_for(trace_id):
  997. trace = await self.get_trace(trace_id)
  998. if trace is None:
  999. raise ValueError(f"Trace not found: {trace_id}")
  1000. messages = await self.get_trace_messages(trace_id)
  1001. sequences = [message.sequence for message in messages]
  1002. if len(sequences) != len(set(sequences)):
  1003. raise TraceStoreCorruptionError(
  1004. f"duplicate message sequence values in Trace {trace_id}"
  1005. )
  1006. events = await self.get_events(trace_id)
  1007. totals = {
  1008. "total_messages": len(messages),
  1009. "last_sequence": max(sequences, default=0),
  1010. "last_event_id": max(
  1011. (int(event.get("event_id", 0)) for event in events),
  1012. default=0,
  1013. ),
  1014. "total_prompt_tokens": sum(m.prompt_tokens or 0 for m in messages),
  1015. "total_completion_tokens": sum(m.completion_tokens or 0 for m in messages),
  1016. "total_reasoning_tokens": sum(m.reasoning_tokens or 0 for m in messages),
  1017. "total_cache_creation_tokens": sum(
  1018. m.cache_creation_tokens or 0 for m in messages
  1019. ),
  1020. "total_cache_read_tokens": sum(
  1021. m.cache_read_tokens or 0 for m in messages
  1022. ),
  1023. "total_tokens": sum(m.tokens for m in messages),
  1024. "total_cost": sum(m.cost or 0.0 for m in messages),
  1025. "total_duration_ms": sum(m.duration_ms or 0 for m in messages),
  1026. }
  1027. await self.update_trace(trace_id, **totals)
  1028. return {
  1029. "messages": totals["total_messages"],
  1030. "last_sequence": totals["last_sequence"],
  1031. "last_event_id": totals["last_event_id"],
  1032. }
  1033. # ===== Cognition Log 管理 =====
  1034. def _get_cognition_log_file(self, trace_id: str) -> Path:
  1035. """获取 cognition_log.json 文件路径"""
  1036. return self._get_trace_dir(trace_id) / "cognition_log.json"
  1037. def _get_knowledge_log_file(self, trace_id: str) -> Path:
  1038. """兼容旧接口:优先使用 cognition_log,回退到 knowledge_log"""
  1039. cognition_file = self._get_cognition_log_file(trace_id)
  1040. if cognition_file.exists():
  1041. return cognition_file
  1042. legacy_file = self._get_trace_dir(trace_id) / "knowledge_log.json"
  1043. if legacy_file.exists():
  1044. return legacy_file
  1045. return cognition_file # 新建时用 cognition_log
  1046. async def get_cognition_log(self, trace_id: str) -> Dict[str, Any]:
  1047. """读取认知日志"""
  1048. log_file = self._get_cognition_log_file(trace_id)
  1049. try:
  1050. if log_file.exists():
  1051. data = json.loads(log_file.read_text(encoding="utf-8"))
  1052. if not isinstance(data, dict):
  1053. raise TypeError("cognition log must be an object")
  1054. return data
  1055. except Exception as exc:
  1056. raise TraceStoreCorruptionError(
  1057. f"corrupt cognition log: {log_file}"
  1058. ) from exc
  1059. # 兼容旧格式:如果只有 knowledge_log.json,读取并转换
  1060. legacy_file = self._get_trace_dir(trace_id) / "knowledge_log.json"
  1061. try:
  1062. if legacy_file.exists():
  1063. data = json.loads(legacy_file.read_text(encoding="utf-8"))
  1064. if not isinstance(data, dict):
  1065. raise TypeError("legacy knowledge log must be an object")
  1066. return data
  1067. except Exception as exc:
  1068. raise TraceStoreCorruptionError(
  1069. f"corrupt knowledge log: {legacy_file}"
  1070. ) from exc
  1071. return {"trace_id": trace_id, "events": []}
  1072. async def get_knowledge_log(self, trace_id: str) -> Dict[str, Any]:
  1073. """兼容旧接口"""
  1074. log = await self.get_cognition_log(trace_id)
  1075. # 旧格式用 entries,新格式用 events
  1076. if "entries" not in log and "events" in log:
  1077. log["entries"] = log["events"]
  1078. return log
  1079. async def append_cognition_event(
  1080. self,
  1081. trace_id: str,
  1082. event: Dict[str, Any],
  1083. ) -> None:
  1084. """追加认知事件到 cognition_log.json。
  1085. 所有事件共有字段:
  1086. type: str 事件类型(见下表)
  1087. timestamp: str ISO 格式时间戳(框架自动写入)
  1088. 已定义的事件类型及典型字段:
  1089. type="query" — 知识注入查询(goal focus 时触发)
  1090. sequence, goal_id, query, response, source_ids, sources
  1091. type="evaluation" — 知识评估(Goal 完成/压缩前/任务结束触发)
  1092. knowledge_id, eval_result{relevance, utility, notes}, trigger_event
  1093. type="extraction_pending" — 反思侧分支暂存的待审核提取(Phase 1.2+)
  1094. extraction_id, sequence, goal_id, branch_id, payload
  1095. (payload 字段与 knowledge_save 参数一一对应)
  1096. type="extraction_reviewed" — 人工审核决策(CLI / HTTP API 写入)
  1097. extraction_id, decision("approve"/"edit"/"discard"), edited_payload?
  1098. type="extraction_committed" — 已上传到 KnowHub
  1099. extraction_id, knowledge_id
  1100. type="reflection" — Dream 的 per-trace 反思摘要(Phase 2.4 / 3.1)
  1101. sequence_range: [start, end] 本次反思覆盖的消息区间
  1102. summary: str LLM 生成的反思摘要
  1103. consumed_at: 可选, ISO 时间戳 当跨 trace 整合已消化此反思时写入
  1104. 其他字段可按需附加,不做强校验(演进友好)。
  1105. """
  1106. async with self._lock_for(trace_id):
  1107. log = await self.get_cognition_log(trace_id)
  1108. if "events" not in log:
  1109. log["events"] = log.pop("entries", [])
  1110. event = dict(event)
  1111. event["timestamp"] = datetime.now().isoformat()
  1112. log["events"].append(event)
  1113. self._atomic_write_json(self._get_cognition_log_file(trace_id), log)
  1114. async def mark_reflections_consumed(
  1115. self,
  1116. trace_id: str,
  1117. reflections: List[Dict[str, Any]],
  1118. consumed_at: str,
  1119. ) -> int:
  1120. """Atomically mark the specified still-pending reflection events consumed."""
  1121. wanted = {
  1122. (
  1123. tuple(item.get("sequence_range") or []),
  1124. str(item.get("summary") or ""),
  1125. )
  1126. for item in reflections
  1127. }
  1128. async with self._lock_for(trace_id):
  1129. log = await self.get_cognition_log(trace_id)
  1130. events = log.get("events", log.get("entries", []))
  1131. changed = 0
  1132. for event in events:
  1133. key = (
  1134. tuple(event.get("sequence_range") or []),
  1135. str(event.get("summary") or ""),
  1136. )
  1137. if (
  1138. event.get("type") == "reflection"
  1139. and not event.get("consumed_at")
  1140. and key in wanted
  1141. ):
  1142. event["consumed_at"] = consumed_at
  1143. changed += 1
  1144. if changed:
  1145. self._atomic_write_json(
  1146. self._get_cognition_log_file(trace_id),
  1147. log,
  1148. )
  1149. return changed
  1150. async def append_knowledge_entry(
  1151. self,
  1152. trace_id: str,
  1153. knowledge_id: str,
  1154. goal_id: str,
  1155. injected_at_sequence: int,
  1156. task: str,
  1157. content: str
  1158. ) -> None:
  1159. """兼容旧接口:追加知识注入记录(转换为 query 事件)"""
  1160. await self.append_cognition_event(
  1161. trace_id=trace_id,
  1162. event={
  1163. "type": "query",
  1164. "sequence": injected_at_sequence,
  1165. "goal_id": goal_id,
  1166. "query": task,
  1167. "response": "",
  1168. "source_ids": [knowledge_id],
  1169. "sources": [{"id": knowledge_id, "task": task, "content": content[:500]}],
  1170. }
  1171. )
  1172. async def update_knowledge_evaluation(
  1173. self,
  1174. trace_id: str,
  1175. knowledge_id: str,
  1176. eval_result: Dict[str, Any],
  1177. trigger_event: str
  1178. ) -> None:
  1179. """更新知识评估结果(兼容旧格式 + 新 cognition_log 格式)
  1180. 旧格式:更新 entries[] 中匹配 knowledge_id 的条目的 eval_result
  1181. 新格式:追加 evaluation 事件到 events[]
  1182. """
  1183. async with self._lock_for(trace_id):
  1184. await self._update_knowledge_evaluation_locked(
  1185. trace_id,
  1186. knowledge_id,
  1187. eval_result,
  1188. trigger_event,
  1189. )
  1190. async def _update_knowledge_evaluation_locked(
  1191. self,
  1192. trace_id: str,
  1193. knowledge_id: str,
  1194. eval_result: Dict[str, Any],
  1195. trigger_event: str,
  1196. ) -> None:
  1197. log = await self.get_cognition_log(trace_id)
  1198. events = log.get("events", log.get("entries", []))
  1199. # 旧格式兼容:直接更新 entries 中的 eval_result 字段
  1200. if "entries" in log:
  1201. matching = [
  1202. (i, e) for i, e in enumerate(log["entries"])
  1203. if e.get("knowledge_id") == knowledge_id and e.get("eval_result") is None
  1204. ]
  1205. if matching:
  1206. matching.sort(key=lambda x: x[1].get("injected_at_sequence", 0), reverse=True)
  1207. _, entry = matching[0]
  1208. entry["eval_result"] = eval_result
  1209. entry["evaluated_at"] = datetime.now().isoformat()
  1210. entry["evaluated_at_trigger"] = trigger_event
  1211. self._atomic_write_json(
  1212. self._get_knowledge_log_file(trace_id),
  1213. log,
  1214. )
  1215. return
  1216. # 新格式:追加 evaluation 事件
  1217. # 找到包含该 knowledge_id 的最近 query 事件
  1218. query_events = [
  1219. e for e in events
  1220. if e.get("type") == "query" and knowledge_id in e.get("source_ids", [])
  1221. ]
  1222. query_sequence = query_events[-1]["sequence"] if query_events else None
  1223. await self.append_cognition_event(
  1224. trace_id=trace_id,
  1225. event={
  1226. "type": "evaluation",
  1227. "sequence": max((e.get("sequence", 0) for e in events), default=0) + 1,
  1228. "query_sequence": query_sequence,
  1229. "trigger": trigger_event,
  1230. "assessments": [
  1231. {"source_id": knowledge_id, "status": eval_result.get("eval_status", ""), "reason": eval_result.get("reason", "")}
  1232. ],
  1233. }
  1234. )
  1235. async def get_pending_knowledge_entries(self, trace_id: str) -> List[Dict[str, Any]]:
  1236. """获取所有待评估的知识条目(兼容旧格式 + 新格式)"""
  1237. log = await self.get_cognition_log(trace_id)
  1238. # 旧格式
  1239. if "entries" in log:
  1240. return [e for e in log["entries"] if e.get("eval_result") is None]
  1241. # 新格式:找没有对应 evaluation 事件的 query 事件
  1242. events = log.get("events", [])
  1243. query_events = [e for e in events if e.get("type") == "query"]
  1244. eval_events = [e for e in events if e.get("type") == "evaluation"]
  1245. # 已评估的 query sequences
  1246. evaluated_sequences = {e.get("query_sequence") for e in eval_events}
  1247. pending = []
  1248. for qe in query_events:
  1249. if qe.get("sequence") not in evaluated_sequences:
  1250. # 转为旧格式兼容(runner 中的评估逻辑期望此格式)
  1251. for source in qe.get("sources", []):
  1252. pending.append({
  1253. "knowledge_id": source.get("id", ""),
  1254. "goal_id": qe.get("goal_id", ""),
  1255. "injected_at_sequence": qe.get("sequence", 0),
  1256. "task": source.get("task", ""),
  1257. "content": source.get("content", ""),
  1258. "query_sequence": qe.get("sequence"),
  1259. })
  1260. return pending
  1261. async def update_user_feedback(
  1262. self,
  1263. trace_id: str,
  1264. knowledge_id: str,
  1265. user_feedback: Dict[str, Any]
  1266. ) -> None:
  1267. """记录用户对知识的反馈(confirm/override)"""
  1268. async with self._lock_for(trace_id):
  1269. await self._update_user_feedback_locked(
  1270. trace_id,
  1271. knowledge_id,
  1272. user_feedback,
  1273. )
  1274. async def _update_user_feedback_locked(
  1275. self,
  1276. trace_id: str,
  1277. knowledge_id: str,
  1278. user_feedback: Dict[str, Any],
  1279. ) -> None:
  1280. log = await self.get_cognition_log(trace_id)
  1281. # 旧格式
  1282. if "entries" in log:
  1283. matching = [
  1284. (i, e) for i, e in enumerate(log["entries"])
  1285. if e.get("knowledge_id") == knowledge_id
  1286. ]
  1287. if matching:
  1288. matching.sort(key=lambda x: x[1].get("injected_at_sequence", 0), reverse=True)
  1289. _, entry = matching[0]
  1290. entry["user_feedback"] = user_feedback
  1291. self._atomic_write_json(
  1292. self._get_knowledge_log_file(trace_id),
  1293. log,
  1294. )
  1295. return
  1296. # 新格式:追加 user_feedback 事件(或直接记录在 evaluation 上)
  1297. await self.append_cognition_event(
  1298. trace_id=trace_id,
  1299. event={
  1300. "type": "user_feedback",
  1301. "knowledge_id": knowledge_id,
  1302. "feedback": user_feedback,
  1303. }
  1304. )