P1:Streaming Core 与事件契约¶
状态:Completed(2026-08-07)
前置条件:P0 门禁通过。
目标:流解析和最终结果不再依赖 UI sink 的可变状态。
1. 目标¶
建立 UI-independent 的流事件契约、运行时 accumulator 和兼容 adapter,将 LangGraph 原始事件解释为可供任意前端消费的语义事件。
P1 结束时,现有 CLI/TUI 行为保持不变,但核心解析代码可以在不导入 synapse.ui 的环境中测试。
2. 核心问题¶
当前 stream_agent() 同时承担:
- 选择 async/sync stream。
- 解析 LangGraph messages/updates。
- answer/reasoning/tool 去重与匹配。
- usage 和 token rate 聚合。
- cancel、repair、HITL 检测。
- 调用 UI sink。
- 从
sink.answer_buf、sink.reasoning_buf和sink.streamed_answer构造结果。
最后一项意味着消费者是运行结果真源,无法支持无订阅者运行。
3. 目标模块¶
建议新增:
src/synapse/runtime/streaming/
__init__.py
events.py
accumulator.py
protocol.py
normalize.py
runner.py
adapters.py
迁移期保留:
src/synapse/ui/stream.py Rich renderer + 兼容 re-export
src/synapse/ui/stream_events.py 兼容 re-export
src/synapse/ui/stream_runtime.py 兼容 re-export
src/synapse/ui/sink.py 兼容协议入口
4. 事件契约¶
4.1 Envelope¶
@dataclass(frozen=True, slots=True)
class TurnEvent:
version: int
thread_id: str
turn_id: str
sequence: int
kind: TurnEventKind
payload: TurnEventPayload
约束:
sequence从 1 开始,在单个 turn 内严格递增。version初始为 1;破坏性 payload 变化必须提升版本。- payload 不含 Textual、Rich renderable、SQLite connection 或 Agent 实例。
- completed/failed/cancelled 是互斥终态,最多出现一次。
4.2 Payload¶
建议使用 typed dataclass,而不是无约束 dict[str, Any]:
ActivityChanged(phase, detail, reset_timer)
TextDelta(text, message_id)
TextCompleted(text, message_id)
ToolCallSnapshot(call_id, name, args, intent)
ToolItemSnapshot(id, call_id, name, category, label, path, parent_id, sub)
ToolFinished(id, status, preview, error, workspace_changed)
UsageSnapshot(...)
TurnTerminal(status, result_summary, error)
原始工具 args 必须有界。超大或不可序列化字段使用安全摘要;不要把工具输出正文复制进事件。
5. TurnAccumulator¶
Runtime-owned accumulator 保存:
- 已见 message/tool/usage ID。
- answer delta/open/complete 文本。
- reasoning delta/open/complete 文本。
- pending tool items 及 parent/subagent 归属。
- usage 总计和最后一次调用数据。
- compact 次数。
- streamed flags。
- 当前状态和终态。
StreamResult 只能从 accumulator 和最终 graph state 构造;不能访问 renderer 或 sink 缓冲区。
为防止无界内存:
- answer/reasoning 的最终正文按现有行为保留,但实时 preview 不重复复制全量字符串。
- info/activity 事件不进入长期 accumulator。
- tool preview 继续使用现有限额。
- dedupe set 在 turn 完成后整体释放。
6. Adapter 策略¶
6.1 新接口¶
实现:
NullEventSinkCollectingEventSinkCompositeEventSinkCallbackEventSink
6.2 Legacy 兼容¶
LegacyStreamSinkAdapter 将标准事件映射回现有 StreamSink 方法:
answer_delta -> write_answer_token
answer_completed -> write_answer_complete
reasoning_delta -> write_reasoning
tool_started -> tool_item_started
usage_updated -> note_usage
info -> info
旧 stream_agent(..., sink=TextualStreamSink(...)) 在迁移期内部自动包装,不改变调用方。
7. Tool 模型迁移¶
ToolItem 当前位于 synapse.ui.timeline,但本身是纯数据模型。P1 应将运行领域模型移动到 runtime/content 中立位置,例如:
UI 的分类、图标、颜色和 group summary 仍留在 ui.timeline。
迁移要求:
- 保留
synapse.ui.timeline.ToolItemre-export。 - 生产代码先使用新路径。
- 搜索扩展调用方后再决定何时删除兼容导出。
8. Runner 抽取¶
runner.py 负责:
- 接收原始 normalized stream item。
- 更新 accumulator。
- 产生
TurnEvent。 - 向 sink emit。
- 构造
StreamResult。
Rich console、Textual host、DOM、Git chrome 和 modal 状态不得进入该模块。
原始 stream 获取可暂时继续使用当前同步桥;真正的 AgentTurnRuntime.arun() 在 P2 完成。
9. Retry 并发安全¶
当前 module-level _retry_notifier 会被并发 turn 覆盖。P1 改为 ContextVar 或等价的显式作用域:
测试必须交错运行两个 notifier,确保 retry 信息回到正确的 collecting sink。
10. 执行计划¶
| ID | 工作 | 主要文件 | 依赖 |
|---|---|---|---|
| P1-01 | 定义事件和 payload | runtime/streaming/events.py | P0 |
| P1-02 | 迁移 ToolItem 领域模型 | runtime/streaming + ui re-export | P1-01 |
| P1-03 | 实现 accumulator | accumulator.py | P1-01 |
| P1-04 | 实现新 sink | protocol.py | P1-01 |
| P1-05 | 实现 legacy adapter | adapters.py | P1-02/P1-04 |
| P1-06 | 抽取 normalizer/runner | normalize.py/runner.py | P1-03/P1-04 |
| P1-07 | retry notifier 作用域化 | runtime/middleware.py | P1-04 |
| P1-08 | 增加旧路径 re-export 并迁移内部调用 | ui/stream*.py | P1-06 |
| P1-09 | 运行契约和兼容门禁 | tests | 全部 |
11. 测试¶
新增建议:
tests/test_runtime_stream_events.py
tests/test_runtime_stream_accumulator.py
tests/test_runtime_stream_adapters.py
tests/test_retry_notifier_scope.py
重点断言:
- 无 sink 时结果完整。
- collecting sink 丢弃任意 preview 事件不影响最终结果。
- sequence 严格递增。
- 终态唯一。
- raw LangChain 对象不会泄漏进 event payload。
- legacy adapter 产生与 P0 trace 相同的行为。
12. 验收标准¶
synapse.runtime.streaming不导入synapse.ui或 Textual。StreamResult不读取外部 sink 属性。- 现有 CLI/TUI 流测试全部通过。
- 旧公共导入路径仍可用。
- 两个并发 retry notifier 不互相覆盖。
- P0 的语义 trace 无非预期变化。
13. 风险与回滚¶
- 风险:去重逻辑在 runtime 和 legacy sink 中重复,导致少消息或重复消息。
- 缓解:P0 trace 驱动,先 adapter 后删旧状态。
- 风险:事件 payload 复制超大 args。
- 缓解:契约层统一有界 snapshot。
- 回滚:保留旧
stream_agent入口;runner 切换应是单独提交,可恢复旧实现而不影响数据。