sse_events.py 9.5 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. """SSE 事件契约(唯一事实源)。
  2. **这个文件定义了 2.0 与 1.5 前端的兼容边界。** 字段名不是猜的,是从
  3. 1.5 的 ``static/index.html`` 的 ``handleSSEEvent`` 逐行反查出来的消费点:
  4. =============== ==========================================================
  5. 事件 前端读取的字段
  6. =============== ==========================================================
  7. ``agent_start`` ``agent`` / ``cn_agent``
  8. ``thinking`` ``message`` / ``cn_agent``
  9. ``thinking_token`` ``content``
  10. ``executing`` ``message`` / ``cn_agent`` / ``tools[]`` / ``cn_tools[]``
  11. ``tool_call`` ``tool`` / ``cn_tool`` / ``agent_id``
  12. ``tool_result`` ``tool`` / ``cn_tool`` / ``agent_id`` / ``download_url`` / ``filename``
  13. ``token`` ``content``
  14. ``updated_todo_list`` ``todos``
  15. ``progress`` ``message`` / ``download_url`` / ``filename``
  16. ``citations`` ``items[]`` / ``model_only``
  17. ``interrupt`` ``tool_approval{tool,args,reason,side_effect}`` 或
  18. ``ask_user{questions[{question,type,choices,required}]}`` / ``message``
  19. ``agent_done`` ``agent`` / ``cn_agent`` / ``duration_ms`` / ``progress``
  20. ``agent_error`` ``agent`` / ``cn_agent`` / ``duration_ms`` / ``progress`` / ``message``
  21. ``done`` ``duration_ms`` / ``download_url`` / ``filename`` / ``session_id``
  22. ``error`` ``message`` / ``code`` / ``retry_after``
  23. =============== ==========================================================
  24. 帧格式:``data: {json}\\n\\n``;每帧恒带 ``session_id``,绑定 trace 时带
  25. ``trace_id``。
  26. **改动纪律**:新增事件或字段是安全的(前端忽略未知字段);重命名或删除
  27. 字段会静默破坏前端渲染——所以所有帧都必须经由 :class:`EventWriter`
  28. 构造,不允许在别处手拼 ``data:`` 字符串。
  29. """
  30. from __future__ import annotations
  31. import json
  32. from typing import Any, Iterable
  33. # 事件类型全集(与 1.5 对齐)
  34. AGENT_START = "agent_start"
  35. THINKING = "thinking"
  36. THINKING_TOKEN = "thinking_token"
  37. EXECUTING = "executing"
  38. TOOL_CALL = "tool_call"
  39. TOOL_RESULT = "tool_result"
  40. UPDATED_TODO_LIST = "updated_todo_list"
  41. TOKEN = "token"
  42. PROGRESS = "progress"
  43. INTERRUPT = "interrupt"
  44. CITATIONS = "citations"
  45. AGENT_DONE = "agent_done"
  46. AGENT_ERROR = "agent_error"
  47. ERROR = "error"
  48. DONE = "done"
  49. ALL_EVENT_TYPES: tuple[str, ...] = (
  50. AGENT_START,
  51. THINKING,
  52. THINKING_TOKEN,
  53. EXECUTING,
  54. TOOL_CALL,
  55. TOOL_RESULT,
  56. UPDATED_TODO_LIST,
  57. TOKEN,
  58. PROGRESS,
  59. INTERRUPT,
  60. CITATIONS,
  61. AGENT_DONE,
  62. AGENT_ERROR,
  63. ERROR,
  64. DONE,
  65. )
  66. # 错误码 → 前端提示文案。前端会用 event.code 查这张表兜底。
  67. ERROR_MESSAGES: dict[str, str] = {
  68. "quota_exceeded": "今日额度已用尽",
  69. "too_many_tasks": "并发任务已达上限,请稍后重试",
  70. "task_timeout": "任务执行超时",
  71. "recursion_limit": "任务步骤过多已中止,请拆分为更小的任务",
  72. "model_error": "模型调用失败",
  73. "internal_error": "服务内部错误",
  74. }
  75. class EventWriter:
  76. """把事件序列化为 SSE 帧。
  77. 一次请求一个实例,绑定 ``session_id`` 与 ``trace_id``——这两个字段
  78. 每帧都要带,放在实例里避免每个方法都传一遍。
  79. """
  80. def __init__(self, session_id: str, trace_id: str = "") -> None:
  81. self.session_id = session_id
  82. self.trace_id = trace_id
  83. # -------- 底层 --------
  84. def frame(self, event_type: str, **fields: Any) -> str:
  85. """构造一帧。``type`` 与 ``session_id`` 由本方法统一注入。"""
  86. payload: dict[str, Any] = {"type": event_type, "session_id": self.session_id}
  87. if self.trace_id:
  88. payload["trace_id"] = self.trace_id
  89. payload.update(fields)
  90. return f"data: {json.dumps(payload, ensure_ascii=False, default=str)}\n\n"
  91. def comment(self, text: str) -> str:
  92. """SSE 注释帧,用作心跳。客户端会忽略,但能防止中间代理掐空闲连接。"""
  93. return f": {text}\n\n"
  94. # -------- 生命周期 --------
  95. def agent_start(self, agent: str, cn_agent: str = "") -> str:
  96. return self.frame(AGENT_START, agent=agent, cn_agent=cn_agent or agent)
  97. def agent_done(
  98. self, agent: str, cn_agent: str = "", duration_ms: int | None = None, **extra: Any
  99. ) -> str:
  100. fields: dict[str, Any] = {"agent": agent, "cn_agent": cn_agent or agent}
  101. if duration_ms is not None:
  102. fields["duration_ms"] = duration_ms
  103. fields.update(extra)
  104. return self.frame(AGENT_DONE, **fields)
  105. def agent_error(
  106. self, agent: str, message: str, cn_agent: str = "", duration_ms: int | None = None
  107. ) -> str:
  108. fields: dict[str, Any] = {
  109. "agent": agent,
  110. "cn_agent": cn_agent or agent,
  111. "message": message,
  112. }
  113. if duration_ms is not None:
  114. fields["duration_ms"] = duration_ms
  115. return self.frame(AGENT_ERROR, **fields)
  116. def done(self, duration_ms: int | None = None, **extra: Any) -> str:
  117. fields: dict[str, Any] = {}
  118. if duration_ms is not None:
  119. fields["duration_ms"] = duration_ms
  120. fields.update(extra)
  121. return self.frame(DONE, **fields)
  122. def error(
  123. self, message: str, code: str = "internal_error", retry_after: int | None = None
  124. ) -> str:
  125. fields: dict[str, Any] = {"message": message, "code": code}
  126. if retry_after is not None:
  127. fields["retry_after"] = retry_after
  128. return self.frame(ERROR, **fields)
  129. # -------- 过程 --------
  130. def thinking(self, message: str, cn_agent: str = "") -> str:
  131. fields: dict[str, Any] = {"message": message}
  132. if cn_agent:
  133. fields["cn_agent"] = cn_agent
  134. return self.frame(THINKING, **fields)
  135. def thinking_token(self, content: str) -> str:
  136. return self.frame(THINKING_TOKEN, content=content)
  137. def executing(
  138. self,
  139. message: str,
  140. cn_agent: str = "",
  141. tools: Iterable[str] | None = None,
  142. cn_tools: Iterable[str] | None = None,
  143. ) -> str:
  144. fields: dict[str, Any] = {"message": message}
  145. if cn_agent:
  146. fields["cn_agent"] = cn_agent
  147. tool_list = list(tools or [])
  148. if tool_list:
  149. fields["tools"] = tool_list
  150. fields["cn_tools"] = list(cn_tools or []) or tool_list
  151. return self.frame(EXECUTING, **fields)
  152. def tool_call(
  153. self,
  154. tool: str,
  155. args: Any = None,
  156. cn_tool: str = "",
  157. agent_id: str = "",
  158. tool_call_id: str = "",
  159. ) -> str:
  160. """工具调用卡。
  161. ``tool_call_id`` 是 **2.0 的附加字段**(1.5 没有):同一轮里同一个工具
  162. 可能被调多次,只按 ``tool`` 名索引卡片会导致结果挂到错误的卡上。
  163. 附加字段不破坏兼容——旧前端忽略未知字段即可。
  164. """
  165. fields: dict[str, Any] = {"tool": tool, "cn_tool": cn_tool or tool, "args": args}
  166. if tool_call_id:
  167. fields["tool_call_id"] = tool_call_id
  168. if agent_id:
  169. fields["agent_id"] = agent_id
  170. return self.frame(TOOL_CALL, **fields)
  171. def tool_result(
  172. self,
  173. tool: str,
  174. content: Any = None,
  175. cn_tool: str = "",
  176. agent_id: str = "",
  177. error: bool = False,
  178. tool_call_id: str = "",
  179. ) -> str:
  180. """工具结果卡。``tool_call_id`` 与对应的 tool_call 帧一致。"""
  181. fields: dict[str, Any] = {
  182. "tool": tool,
  183. "cn_tool": cn_tool or tool,
  184. "content": content,
  185. "error": error,
  186. }
  187. if tool_call_id:
  188. fields["tool_call_id"] = tool_call_id
  189. if agent_id:
  190. fields["agent_id"] = agent_id
  191. return self.frame(TOOL_RESULT, **fields)
  192. def token(self, content: str) -> str:
  193. return self.frame(TOKEN, content=content)
  194. def todo_list(self, todos: list[dict[str, Any]]) -> str:
  195. return self.frame(UPDATED_TODO_LIST, todos=todos)
  196. def progress(self, message: str, **extra: Any) -> str:
  197. fields: dict[str, Any] = {"message": message}
  198. fields.update(extra)
  199. return self.frame(PROGRESS, **fields)
  200. def citations(self, items: list[dict[str, Any]], model_only: bool = False) -> str:
  201. return self.frame(CITATIONS, items=items, model_only=model_only)
  202. # -------- 中断 --------
  203. def interrupt_tool_approval(
  204. self,
  205. tool: str,
  206. args: Any = None,
  207. reason: str = "",
  208. side_effect: str = "",
  209. **extra: Any,
  210. ) -> str:
  211. """工具审批卡(对应前端 ``showToolApprovalCard``)。"""
  212. payload: dict[str, Any] = {
  213. "tool": tool,
  214. "args": args,
  215. "reason": reason,
  216. "side_effect": side_effect,
  217. }
  218. payload.update(extra)
  219. return self.frame(INTERRUPT, tool_approval=payload)
  220. def interrupt_ask_user(
  221. self, questions: list[dict[str, Any]], message: str = ""
  222. ) -> str:
  223. """提问卡(对应前端 ``showAskUserCard``)。
  224. 每个问题形如 ``{"question": str, "type": "text"|"multiple_choice",
  225. "choices": [...], "required": bool}``。
  226. """
  227. return self.frame(
  228. INTERRUPT,
  229. ask_user={"questions": questions},
  230. message=message or "智能体暂停,等待输入",
  231. )
  232. def interrupt_raw(self, message: str = "", **extra: Any) -> str:
  233. """无法归类的中断:只给一条提示,前端显示 toast。"""
  234. return self.frame(INTERRUPT, message=message, **extra)