models.py 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576
  1. """
  2. Trace 和 Message 数据模型
  3. Trace: 一次完整的 LLM 交互(单次调用或 Agent 任务)
  4. Message: Trace 中的 LLM 消息,对应 LLM API 格式
  5. """
  6. from dataclasses import dataclass, field
  7. from datetime import datetime
  8. from typing import Dict, Any, List, Optional, Literal, Union
  9. import uuid
  10. # ===== 消息线格式类型别名 =====
  11. # 轻量 wire-format 类型,用于工具参数和 runner/LLM API 接口。
  12. # 内部存储使用下方的 Message dataclass。
  13. ChatMessage = Dict[str, Any] # 单条 OpenAI 格式消息
  14. Messages = List[ChatMessage] # 消息列表
  15. MessageContent = Union[str, List[Dict[str, str]]] # content 字段(文本或多模态)
  16. # 导入 TokenUsage(延迟导入避免循环依赖)
  17. def _get_token_usage_class():
  18. from ..llm.usage import TokenUsage
  19. return TokenUsage
  20. @dataclass
  21. class Trace:
  22. """
  23. 执行轨迹 - 一次完整的 LLM 交互
  24. 单次调用: mode="call"
  25. Agent 模式: mode="agent"
  26. 主 Trace 和 Sub-Trace 使用相同的数据结构。
  27. Sub-Trace 通过 parent_trace_id 和 parent_goal_id 关联父 Trace。
  28. """
  29. trace_id: str
  30. mode: Literal["call", "agent"]
  31. # Prompt 标识(可选)
  32. prompt_name: Optional[str] = None
  33. # Agent 模式特有
  34. task: Optional[str] = None
  35. agent_type: Optional[str] = None
  36. # 父子关系(Sub-Trace 特有)
  37. parent_trace_id: Optional[str] = None # 父 Trace ID
  38. parent_goal_id: Optional[str] = None # 哪个 Goal 启动的
  39. # 状态
  40. status: Literal[
  41. "running",
  42. "waiting_confirmation",
  43. "completed",
  44. "failed",
  45. "stopped",
  46. ] = "running"
  47. # 统计
  48. total_messages: int = 0 # 消息总数(改名自 total_steps)
  49. total_tokens: int = 0 # 总 tokens(向后兼容,= prompt + completion)
  50. total_prompt_tokens: int = 0 # 总输入 tokens
  51. total_completion_tokens: int = 0 # 总输出 tokens
  52. total_reasoning_tokens: int = 0 # 总推理 tokens(o1/o3, DeepSeek R1, Gemini thinking)
  53. total_cache_creation_tokens: int = 0 # 总缓存创建 tokens(Claude)
  54. total_cache_read_tokens: int = 0 # 总缓存读取 tokens(Claude)
  55. total_cost: float = 0.0
  56. total_duration_ms: int = 0 # 总耗时(毫秒)
  57. # 进度追踪(head)
  58. last_sequence: int = 0 # 最新 message 的 sequence
  59. head_sequence: int = 0 # 当前主路径的头节点 sequence(用于 build_llm_messages)
  60. last_event_id: int = 0 # 最新事件 ID(用于 WS 续传)
  61. # 配置
  62. uid: Optional[str] = None
  63. model: Optional[str] = None # 默认模型
  64. tools: Optional[List[Dict]] = None # 工具定义(整个 trace 共享)
  65. llm_params: Dict[str, Any] = field(default_factory=dict) # LLM 参数(temperature 等)
  66. context: Dict[str, Any] = field(default_factory=dict) # 其他元数据
  67. # 当前焦点 goal
  68. current_goal_id: Optional[str] = None
  69. # Memory 系统 - 记忆反思的进度追踪(见 cyber_agent/docs/memory.md 第四节)
  70. # dream 操作扫描 reflected_at_sequence < latest_sequence 的 trace 做反思;
  71. # None 表示该 trace 从未被记忆反思处理过。
  72. reflected_at_sequence: Optional[int] = None
  73. # 结果
  74. result_summary: Optional[str] = None # 执行结果摘要
  75. error_message: Optional[str] = None # 错误信息
  76. # 时间
  77. created_at: datetime = field(default_factory=datetime.now)
  78. completed_at: Optional[datetime] = None
  79. last_activity_at: datetime = field(default_factory=datetime.now) # 最后活动时间(用于判断是否真正运行中)
  80. @classmethod
  81. def create(
  82. cls,
  83. mode: Literal["call", "agent"],
  84. **kwargs
  85. ) -> "Trace":
  86. """创建新的 Trace"""
  87. return cls(
  88. trace_id=str(uuid.uuid4()),
  89. mode=mode,
  90. **kwargs
  91. )
  92. @classmethod
  93. def from_dict(cls, data: Dict[str, Any]) -> "Trace":
  94. """从字典创建 Trace(处理日期字段反序列化)"""
  95. from dateutil import parser
  96. # 处理日期字段
  97. if "created_at" in data and isinstance(data["created_at"], str):
  98. data["created_at"] = parser.isoparse(data["created_at"])
  99. if "completed_at" in data and isinstance(data["completed_at"], str):
  100. data["completed_at"] = parser.isoparse(data["completed_at"])
  101. if "last_activity_at" in data and isinstance(data["last_activity_at"], str):
  102. data["last_activity_at"] = parser.isoparse(data["last_activity_at"])
  103. return cls(**data)
  104. def to_dict(self) -> Dict[str, Any]:
  105. """转换为字典"""
  106. return {
  107. "trace_id": self.trace_id,
  108. "mode": self.mode,
  109. "prompt_name": self.prompt_name,
  110. "task": self.task,
  111. "agent_type": self.agent_type,
  112. "parent_trace_id": self.parent_trace_id,
  113. "parent_goal_id": self.parent_goal_id,
  114. "status": self.status,
  115. "total_messages": self.total_messages,
  116. "total_tokens": self.total_tokens,
  117. "total_prompt_tokens": self.total_prompt_tokens,
  118. "total_completion_tokens": self.total_completion_tokens,
  119. "total_reasoning_tokens": self.total_reasoning_tokens,
  120. "total_cache_creation_tokens": self.total_cache_creation_tokens,
  121. "total_cache_read_tokens": self.total_cache_read_tokens,
  122. "total_cost": self.total_cost,
  123. "total_duration_ms": self.total_duration_ms,
  124. "last_sequence": self.last_sequence,
  125. "head_sequence": self.head_sequence,
  126. "last_event_id": self.last_event_id,
  127. "uid": self.uid,
  128. "model": self.model,
  129. "tools": self.tools,
  130. "llm_params": self.llm_params,
  131. "context": self.context,
  132. "current_goal_id": self.current_goal_id,
  133. "reflected_at_sequence": self.reflected_at_sequence,
  134. "result_summary": self.result_summary,
  135. "error_message": self.error_message,
  136. "created_at": self.created_at.isoformat() if self.created_at else None,
  137. "completed_at": self.completed_at.isoformat() if self.completed_at else None,
  138. "last_activity_at": self.last_activity_at.isoformat() if self.last_activity_at else None,
  139. }
  140. @dataclass
  141. class Message:
  142. """
  143. 执行消息 - Trace 中的 LLM 消息
  144. 对应 LLM API 消息格式(system/user/assistant/tool),通过 goal_id 关联 Goal。
  145. description 字段自动生成规则:
  146. - system: 取 content 前 200 字符
  147. - user: 取 content 前 200 字符
  148. - assistant: 优先取 content,若无 content 则生成 "tool call: XX, XX"
  149. - tool: 使用 tool name
  150. """
  151. message_id: str
  152. trace_id: str
  153. role: Literal["system", "user", "assistant", "tool"] # 和 LLM API 一致
  154. sequence: int # 全局顺序
  155. parent_sequence: Optional[int] = None # 父消息的 sequence(构成消息树)
  156. status: Literal["active", "abandoned"] = "active" # [已弃用] 由 parent_sequence 树结构替代
  157. goal_id: Optional[str] = None # 关联的 Goal 内部 ID(None = 还没有创建 Goal)
  158. description: str = "" # 消息描述(系统自动生成)
  159. tool_call_id: Optional[str] = None # tool 消息关联对应的 tool_call
  160. content: Any = None # 消息内容(和 LLM API 格式一致)
  161. # 侧分支标记
  162. branch_type: Optional[Literal["compression", "reflection", "knowledge_eval"]] = None # 侧分支类型(None = 主路径)
  163. branch_id: Optional[str] = None # 侧分支 ID(同一侧分支的消息共享)
  164. # 元数据
  165. prompt_tokens: Optional[int] = None # 输入 tokens
  166. completion_tokens: Optional[int] = None # 输出 tokens
  167. reasoning_tokens: Optional[int] = None # 推理 tokens(o1/o3, DeepSeek R1, Gemini thinking)
  168. cache_creation_tokens: Optional[int] = None # 缓存创建 tokens(Claude)
  169. cache_read_tokens: Optional[int] = None # 缓存读取 tokens(Claude)
  170. cost: Optional[float] = None
  171. duration_ms: Optional[int] = None
  172. created_at: datetime = field(default_factory=datetime.now)
  173. abandoned_at: Optional[datetime] = None # [已弃用] 由 parent_sequence 树结构替代
  174. # LLM 响应信息(仅 role="assistant" 时使用)
  175. finish_reason: Optional[str] = None # stop, length, tool_calls, content_filter 等
  176. @property
  177. def tokens(self) -> int:
  178. """动态计算总 tokens(向后兼容,input + output)"""
  179. return (self.prompt_tokens or 0) + (self.completion_tokens or 0)
  180. @property
  181. def all_tokens(self) -> int:
  182. """所有 tokens(包括 reasoning)"""
  183. return self.tokens + (self.reasoning_tokens or 0)
  184. def get_usage(self):
  185. """获取 TokenUsage 对象"""
  186. TokenUsage = _get_token_usage_class()
  187. return TokenUsage(
  188. input_tokens=self.prompt_tokens or 0,
  189. output_tokens=self.completion_tokens or 0,
  190. reasoning_tokens=self.reasoning_tokens or 0,
  191. cache_creation_tokens=self.cache_creation_tokens or 0,
  192. cache_read_tokens=self.cache_read_tokens or 0,
  193. )
  194. def to_llm_dict(self) -> Dict[str, Any]:
  195. """转换为 OpenAI SDK 格式的消息字典(用于 LLM 调用)"""
  196. msg: Dict[str, Any] = {"role": self.role, "_message_id": self.message_id}
  197. if self.role == "tool":
  198. # tool message: tool_call_id + name + content
  199. if self.tool_call_id:
  200. msg["tool_call_id"] = self.tool_call_id
  201. msg["name"] = self.description or "unknown"
  202. if isinstance(self.content, dict):
  203. result = self.content.get("result", self.content)
  204. # result 可能是 list(含图片的多模态内容)或字符串
  205. msg["content"] = result if isinstance(result, list) else str(result)
  206. else:
  207. msg["content"] = str(self.content) if self.content is not None else ""
  208. elif self.role == "assistant":
  209. # assistant message: content(text) + tool_calls
  210. if isinstance(self.content, dict):
  211. msg["content"] = self.content.get("text", "") or ""
  212. if self.content.get("tool_calls"):
  213. msg["tool_calls"] = self.content["tool_calls"]
  214. elif isinstance(self.content, str):
  215. msg["content"] = self.content
  216. else:
  217. msg["content"] = ""
  218. else:
  219. # system / user message: content 直接传
  220. msg["content"] = self.content
  221. return msg
  222. @classmethod
  223. def from_llm_dict(
  224. cls,
  225. d: Dict[str, Any],
  226. trace_id: str,
  227. sequence: int,
  228. goal_id: Optional[str] = None,
  229. parent_sequence: Optional[int] = None,
  230. ) -> "Message":
  231. """从 OpenAI SDK 格式创建 Message"""
  232. role = d["role"]
  233. if role == "assistant":
  234. content = {"text": d.get("content", ""), "tool_calls": d.get("tool_calls")}
  235. elif role == "tool":
  236. content = {"tool_name": d.get("name", "unknown"), "result": d.get("content", "")}
  237. else:
  238. content = d.get("content", "")
  239. return cls.create(
  240. trace_id=trace_id,
  241. role=role,
  242. sequence=sequence,
  243. goal_id=goal_id,
  244. parent_sequence=parent_sequence,
  245. content=content,
  246. tool_call_id=d.get("tool_call_id"),
  247. )
  248. @classmethod
  249. def from_dict(cls, data: Dict[str, Any]) -> "Message":
  250. """从字典创建 Message(处理向后兼容)"""
  251. # 过滤掉已删除的字段
  252. filtered_data = {k: v for k, v in data.items() if k not in ["tokens", "available_tools"]}
  253. # 解析 datetime
  254. if filtered_data.get("created_at") and isinstance(filtered_data["created_at"], str):
  255. filtered_data["created_at"] = datetime.fromisoformat(filtered_data["created_at"])
  256. if filtered_data.get("abandoned_at") and isinstance(filtered_data["abandoned_at"], str):
  257. filtered_data["abandoned_at"] = datetime.fromisoformat(filtered_data["abandoned_at"])
  258. # 向后兼容:旧消息没有 status 字段,默认 active
  259. if "status" not in filtered_data:
  260. filtered_data["status"] = "active"
  261. # 向后兼容:旧消息没有 parent_sequence 字段
  262. if "parent_sequence" not in filtered_data:
  263. filtered_data["parent_sequence"] = None
  264. # 向后兼容:旧消息没有侧分支字段
  265. if "branch_type" not in filtered_data:
  266. filtered_data["branch_type"] = None
  267. if "branch_id" not in filtered_data:
  268. filtered_data["branch_id"] = None
  269. return cls(**filtered_data)
  270. @classmethod
  271. def create(
  272. cls,
  273. trace_id: str,
  274. role: Literal["system", "user", "assistant", "tool"],
  275. sequence: int,
  276. goal_id: Optional[str] = None,
  277. content: Any = None,
  278. tool_call_id: Optional[str] = None,
  279. parent_sequence: Optional[int] = None,
  280. branch_type: Optional[Literal["compression", "reflection", "knowledge_eval"]] = None,
  281. branch_id: Optional[str] = None,
  282. prompt_tokens: Optional[int] = None,
  283. completion_tokens: Optional[int] = None,
  284. reasoning_tokens: Optional[int] = None,
  285. cache_creation_tokens: Optional[int] = None,
  286. cache_read_tokens: Optional[int] = None,
  287. cost: Optional[float] = None,
  288. duration_ms: Optional[int] = None,
  289. finish_reason: Optional[str] = None,
  290. ) -> "Message":
  291. """创建新的 Message,自动生成 description"""
  292. description = cls._generate_description(role, content)
  293. return cls(
  294. message_id=f"{trace_id}-{sequence:04d}",
  295. trace_id=trace_id,
  296. role=role,
  297. sequence=sequence,
  298. parent_sequence=parent_sequence,
  299. goal_id=goal_id,
  300. content=content,
  301. description=description,
  302. tool_call_id=tool_call_id,
  303. branch_type=branch_type,
  304. branch_id=branch_id,
  305. prompt_tokens=prompt_tokens,
  306. completion_tokens=completion_tokens,
  307. reasoning_tokens=reasoning_tokens,
  308. cache_creation_tokens=cache_creation_tokens,
  309. cache_read_tokens=cache_read_tokens,
  310. cost=cost,
  311. duration_ms=duration_ms,
  312. finish_reason=finish_reason,
  313. )
  314. @staticmethod
  315. def _generate_description(role: str, content: Any) -> str:
  316. """
  317. 自动生成 description
  318. - system: 取 content 前 200 字符
  319. - user: 取 content 前 200 字符
  320. - assistant: 优先取 content,若无 content 则生成 "tool call: XX, XX"
  321. - tool: 使用 tool name
  322. """
  323. if role == "system":
  324. # system 消息:直接返回文本
  325. if isinstance(content, str):
  326. return content
  327. return "system prompt"
  328. elif role == "user":
  329. # user 消息:直接返回文本
  330. if isinstance(content, str):
  331. return content
  332. return "user message"
  333. elif role == "assistant":
  334. # assistant 消息:content 是字典,可能包含 text 和 tool_calls
  335. if isinstance(content, dict):
  336. # 优先返回文本内容
  337. if content.get("text"):
  338. text = content["text"]
  339. # 返回完整文本
  340. return text
  341. # 如果没有文本,检查 tool_calls
  342. if content.get("tool_calls"):
  343. tool_calls = content["tool_calls"]
  344. if isinstance(tool_calls, list):
  345. tool_descriptions = []
  346. for tc in tool_calls:
  347. if isinstance(tc, dict) and tc.get("function", {}).get("name"):
  348. tool_name = tc["function"]["name"]
  349. # 提取参数并截断到 100 字符
  350. tool_args = tc["function"].get("arguments", "{}")
  351. if isinstance(tool_args, str):
  352. args_str = tool_args
  353. else:
  354. import json
  355. args_str = json.dumps(tool_args, ensure_ascii=False)
  356. args_display = args_str[:100] + "..." if len(args_str) > 100 else args_str
  357. tool_descriptions.append(f"{tool_name}({args_display})")
  358. if tool_descriptions:
  359. return "tool call: " + ", ".join(tool_descriptions)
  360. # 如果 content 是字符串
  361. if isinstance(content, str):
  362. return content
  363. return "assistant message"
  364. elif role == "tool":
  365. # tool 消息:从 content 中提取 tool name
  366. if isinstance(content, dict):
  367. if content.get("tool_name"):
  368. return content["tool_name"]
  369. # 如果是字符串,尝试解析
  370. if isinstance(content, str):
  371. return content[:100] + "..." if len(content) > 100 else content
  372. return "tool result"
  373. return ""
  374. def to_dict(self) -> Dict[str, Any]:
  375. """转换为字典"""
  376. result = {
  377. "message_id": self.message_id,
  378. "trace_id": self.trace_id,
  379. "role": self.role,
  380. "sequence": self.sequence,
  381. "parent_sequence": self.parent_sequence,
  382. "status": self.status,
  383. "goal_id": self.goal_id,
  384. "tool_call_id": self.tool_call_id,
  385. "content": self.content,
  386. "description": self.description,
  387. "branch_type": self.branch_type,
  388. "branch_id": self.branch_id,
  389. "tokens": self.tokens, # 使用 @property 动态计算
  390. "prompt_tokens": self.prompt_tokens,
  391. "completion_tokens": self.completion_tokens,
  392. "cost": self.cost,
  393. "duration_ms": self.duration_ms,
  394. "finish_reason": self.finish_reason,
  395. "created_at": self.created_at.isoformat() if self.created_at else None,
  396. }
  397. # 只添加非空的可选字段
  398. if self.abandoned_at:
  399. result["abandoned_at"] = self.abandoned_at.isoformat()
  400. if self.reasoning_tokens is not None:
  401. result["reasoning_tokens"] = self.reasoning_tokens
  402. if self.cache_creation_tokens is not None:
  403. result["cache_creation_tokens"] = self.cache_creation_tokens
  404. if self.cache_read_tokens is not None:
  405. result["cache_read_tokens"] = self.cache_read_tokens
  406. return result
  407. # ===== 已弃用:Step 模型(保留用于向后兼容)=====
  408. # Step 类型
  409. StepType = Literal[
  410. "goal", "thought", "evaluation", "response",
  411. "action", "result", "memory_read", "memory_write",
  412. ]
  413. # Step 状态
  414. StepStatus = Literal[
  415. "planned", "in_progress", "awaiting_approval",
  416. "completed", "failed", "skipped",
  417. ]
  418. @dataclass
  419. class Step:
  420. """
  421. [已弃用] 执行步骤 - 使用 Message 模型替代
  422. 保留用于向后兼容
  423. """
  424. step_id: str
  425. trace_id: str
  426. step_type: StepType
  427. status: StepStatus
  428. sequence: int
  429. parent_id: Optional[str] = None
  430. description: str = ""
  431. data: Dict[str, Any] = field(default_factory=dict)
  432. summary: Optional[str] = None
  433. has_children: bool = False
  434. children_count: int = 0
  435. duration_ms: Optional[int] = None
  436. tokens: Optional[int] = None
  437. cost: Optional[float] = None
  438. created_at: datetime = field(default_factory=datetime.now)
  439. @classmethod
  440. def create(
  441. cls,
  442. trace_id: str,
  443. step_type: StepType,
  444. sequence: int,
  445. status: StepStatus = "completed",
  446. description: str = "",
  447. data: Dict[str, Any] = None,
  448. parent_id: Optional[str] = None,
  449. summary: Optional[str] = None,
  450. duration_ms: Optional[int] = None,
  451. tokens: Optional[int] = None,
  452. cost: Optional[float] = None,
  453. ) -> "Step":
  454. """创建新的 Step"""
  455. return cls(
  456. step_id=str(uuid.uuid4()),
  457. trace_id=trace_id,
  458. step_type=step_type,
  459. status=status,
  460. sequence=sequence,
  461. parent_id=parent_id,
  462. description=description,
  463. data=data or {},
  464. summary=summary,
  465. duration_ms=duration_ms,
  466. tokens=tokens,
  467. cost=cost,
  468. )
  469. def to_dict(self, view: str = "full") -> Dict[str, Any]:
  470. """
  471. 转换为字典
  472. Args:
  473. view: "compact" - 不返回大字段
  474. "full" - 返回完整数据
  475. """
  476. result = {
  477. "step_id": self.step_id,
  478. "trace_id": self.trace_id,
  479. "step_type": self.step_type,
  480. "status": self.status,
  481. "sequence": self.sequence,
  482. "parent_id": self.parent_id,
  483. "description": self.description,
  484. "summary": self.summary,
  485. "has_children": self.has_children,
  486. "children_count": self.children_count,
  487. "duration_ms": self.duration_ms,
  488. "tokens": self.tokens,
  489. "cost": self.cost,
  490. "created_at": self.created_at.isoformat() if self.created_at else None,
  491. }
  492. # 处理 data 字段
  493. if view == "compact":
  494. data_copy = self.data.copy()
  495. for key in ["output", "content", "full_output", "full_content"]:
  496. data_copy.pop(key, None)
  497. result["data"] = data_copy
  498. else:
  499. result["data"] = self.data
  500. return result