Files
cursor-byok/docs/Loop-Dialect完整落地方案.md
T
2026-08-13 22:01:11 +08:00

890 lines
24 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Loop + Dialect 完整落地方案
## 1. 设计目标
这是一个从零设计的服务端方案,不依赖当前项目的服务端实现。
目标只有四个:
1. Loop 的状态决策是纯函数,外层运行器用递归驱动它,直到得到最终答案。
2. LLM 使用原生请求和原生流事件,不再造一套平行的模型消息结构。
3. Cursor 的 Bidi、RunSSE、protobuf 只存在于 Dialect 和 Transport 中。
4. `messages` 永远是完整、顺序固定、只追加的历史,保证前缀缓存稳定。
模型调用是无状态的。每一次调用都发送完整的 `RequestMessages`,而不是向模型发送“上一次请求的差异”。
## 2. 顶层结构
代码目录只保留四个模块:
```text
server/
loop/ 纯函数状态转换、消息历史和下一步决定
llm/ 原生请求、原生响应流、供应商适配器
transport/ Connect、Bidi、RunSSE、CursorDialect、运行器
store/ SQLite 状态、调用记录、输入和工具去重
```
`CursorDialect``transport/` 里的协议翻译文件,不单独形成目录。客户端是远端能力:服务端向它发工具请求,它经 Bidi 返回工具结果;因此也不在服务端拆出 `client/` 模块。
`store/` 只是基础设施适配器:它保存状态和提交记录,不决定下一步动作。
依赖方向固定为:
```text
transport -> loop
transport -> llm
transport -> store
CursorDialect (inside transport) -> loop / llm
```
`transport/runner` 是很薄的组装代码:它执行 `Command`,把外部结果再送回 `loop``loop` 不依赖 protobuf、HTTP、SSE、连接对象、Store、客户端或具体 LLM 供应商。
## 3. 三类核心数据
### 3.1 模型请求
直接使用 `internal/backend/cursor/llm/request.go` 中的结构:
```text
RequestMessages {
SystemPrompt
Messages []Message
Tools []ToolDefinition
}
```
这里有一个重要边界:
- `Messages` 是会话历史,必须只追加。
- `SystemPrompt``Tools` 是本次请求构建出来的请求部分。
- 前缀缓存约束只针对 `Messages`
- 不能通过合并、去重、重排或“修正上一条消息”来构建历史。
每次请求的模型上下文都是:
```text
RequestMessages {
SystemPrompt: buildPrompt(input, state)
Messages: state.messages
Tools: buildTools(input, state)
}
```
`buildPrompt``buildTools` 可以每次重新计算,但不能修改 `state.messages`
### 3.2 模型响应
直接使用 `internal/backend/cursor/llm/response.go` 中的结构:
```text
ResponseEvent {
Start
TextStart / TextDelta / TextEnd
ThinkingStart / ThinkingDelta / ThinkingEnd
ToolCallStart / ToolCallDelta / ToolCallEnd
Done
Error
}
```
完整响应使用 `AssistantMessage`。工具结果使用 `ToolResultMessage`。用户输入使用 `UserMessage`
`internal/backend/cursor/llm/stream.go` 中的接口是 LLM 边界:
```text
ResponseStream.Recv(context) -> (ResponseEvent, error)
```
LLM 适配器可以将 OpenAI、Anthropic、Gemini 或其他供应商的响应转换为这些原生中间结构,但不能把供应商私有的流格式泄漏到 Loop。
### 3.3 Loop 输入
Loop 只接收有语义的输入,不接收网络数据:
```text
Input =
Start {
userMessage: UserMessage
context: []ContextSupplement
}
| LLMEvent {
callID: string
event: ResponseEvent
}
| ToolResult {
message: ToolResultMessage
}
| UserMessage {
message: UserMessage
}
| Cancel {
reason: string
}
```
`BidiAppend` 解码后只能生成这些输入。Loop 不需要知道输入原来来自 Bidi、HTTP 还是测试代码。
上下文补充如果要被模型看到,必须转换成新的消息追加到历史;不能回写旧消息:
```text
旧 messages + 新 UserMessage(context supplement)
```
## 4. 状态:已提交部分与正在生成部分
运行中的状态分为两部分:
```text
RuntimeState {
committed ConversationState
pendingResponse *PendingResponse
}
ConversationState {
conversationID
turnID
messages []llm.Message
waiting *WaitingClient
status Ready | WaitingLLM | WaitingClient | Final | Failed | Canceled
lastCommitID string
}
```
### 4.1 `messages`
`messages` 是唯一的模型历史:
- 只能在完整的 `UserMessage``AssistantMessage``ToolResultMessage` 完成后追加。
- 已经追加的消息永远不变。
- 顺序永远按照发生顺序排列。
- 不使用 map 作为模型消息容器。
- 不在重放时重新生成时间戳、随机 ID 或不稳定字段。
- 工具调用的签名、思考签名和供应商响应 ID 原样保留。
### 4.2 `pendingResponse`
`pendingResponse` 是本次 LLM 流的临时聚合器,不属于模型历史:
```text
PendingResponse {
callID
partialMessage
openContentBlocks
openToolCalls
usage
}
```
它只接收流中的增量事件。只有 `Done` 才能把完整的 `AssistantMessage` 追加到 `messages`
`pendingResponse` 只存在于内存。每个 delta 都可以被实时发送给 RunSSE,也可以写入诊断日志,但不会被提交到 `ConversationState`
如果进程在 `Done` 前重启:
```text
丢弃 pendingResponse
保留本次 callID、requestHash 和调用状态
从最后一份已提交的 ConversationState 重试相同请求
```
不恢复半截文本,不把旧 delta 与新响应拼接,也不把旧 delta 当作模型消息。这样即使上游流不可续传,模型历史仍然一致。
## 5. 纯函数转换接口
Loop 的核心函数固定为:
```text
transition(state, input) -> Transition
```
返回值:
```text
Transition {
state
emit []llm.ResponseEvent
command Command
}
```
`emit` 使用 LLM 原生 `ResponseEvent`,不创建 `AssistantTextDelta``ToolCallOutput` 等第二套事件。
`Command` 只有几种:
```text
Command =
ContinueLLM
| CallLLM {
callID
request RequestMessages
messagesHash string
}
| CallClient {
operationID
toolCall ToolCall
}
| WaitInput
| Final {
message AssistantMessage
}
| Failed {
message AssistantMessage
}
| Canceled {
reason string
}
```
`Command` 是普通数据,不能携带闭包、连接、channel 或函数指针。这样它可以记录、重放和比较。
## 6. Loop 的递归规则
核心判断是纯函数:
```text
transition(runtimeState, input) -> Transition
```
它不执行 I/O,也不自行取得下一条输入。递归发生在外层运行器:
```text
run(state, input) {
result = transition(state, input)
publish(result.emit)
commitWhenNeeded(result)
if result.command is Final or Failed or Canceled or WaitInput {
return result
}
return runCommand(result.state, result.command)
}
```
`run` 是递归入口,`transition` 是唯一的状态判断函数。网络和客户端调用不能放进纯函数,因此 `runCommand` 是外层执行器:
```text
runCommand(state, command) {
switch command {
case CallLLM:
return consumeLLM(state, command)
case CallClient:
sendClientCommand(command)
return { state, emit: [], command: WaitInput }
case WaitInput, Final, Failed, Canceled:
return { state, emit: [], command }
case ContinueLLM:
return invalidState("ContinueLLM without stream")
}
}
```
`CallClient` 不能同步等待工具返回。它被编码为外层协议消息后,`runCommand` 立即结束本次调用;之后客户端通过 BidiAppend 提交 `ToolResultMessage`Transport 再次调用 `run(loadedState, ToolResult)`
这里不是从旧调用栈继续等待。工具结果是一个新的外部输入,也是递归的下一层。
实现时可以使用异步尾递归、trampoline 或任务调度器避免实际调用栈无限增长,但不能把业务逻辑改成一个可随意修改历史的可变状态循环。
## 7. LLM 流的处理
### 7.1 启动一次调用
`CallLLM` 携带完整请求和请求快照信息:
```text
CallLLM {
callID
request
messagesHash
}
```
`messagesHash` 是发送前按消息顺序对完整 `Messages` 序列做的稳定哈希,用来确认重试时没有改变历史。重试实际使用已保存的 `exactRequest`,不重新 build prompt、tools 或 messages。
执行器:
```text
consumeLLM(state, command) {
stream = llm.call(command.request)
return readLLM(state, command.callID, stream)
}
```
### 7.2 逐个接收事件
```text
readLLM(state, callID, stream) {
event = stream.Recv()
result = transition(state, LLMEvent(callID, event))
publish(result.emit)
commitWhenNeeded(result)
switch result.command {
case ContinueLLM:
return readLLM(result.state, callID, stream)
case CallClient, CallLLM, WaitInput, Final, Failed, Canceled:
return runCommand(result.state, result.command)
}
}
```
上面的 `CallClient` 分支会发送一个客户端请求并返回 `WaitInput`;它不会占用 LLM 流或阻塞 HTTP handler。下一个 Bidi 输入是另一次 `run(loadedState, input)` 调用。
### 7.3 各事件的状态变化
```text
Start
-> 创建 pendingResponse
-> 原样发布 ResponseEvent.Start
TextStart / ThinkingStart / ToolCallStart
-> 打开对应内容块
-> 原样发布事件
TextDelta / ThinkingDelta / ToolCallDelta
-> 追加到 pendingResponse
-> 原样发布事件
TextEnd / ThinkingEnd / ToolCallEnd
-> 关闭对应内容块
-> 原样发布事件
Done(stop)
-> 校验完整 AssistantMessage
-> 将它追加到 messages
-> 清空 pendingResponse
-> command = Final
Done(toolUse)
-> 追加完整 AssistantMessage
-> 清空 pendingResponse
-> 取第一项未完成工具调用
-> command = CallClient
Error / Aborted
-> 丢弃未完成 pendingResponse
-> 保存错误记录
-> 不把半截响应追加到 messages
-> command = Failed 或 Canceled
```
无论模型返回多少个 delta`messages` 最终只追加一条完整的 `AssistantMessage`
`ResponseEvent.Partial` 是 LLM 适配器提供的当前累计视图。Loop 可以用它校验 `pendingResponse` 或供 RunSSE 重连时显示,但不能用它覆盖、修改或合并任何已提交的 `messages`。唯一允许提交到 `messages` 的助手响应来自 `ResponseEvent.Done.Message`
## 8. 外层流协议的对接
外层协议分两层:
```text
Transport
负责连接、读写、framing、断开、heartbeat
Dialect
负责 protobuf 消息与原生语义结构之间的翻译
```
Loop 只产生 `ResponseEvent``Command`,不直接写 RunSSE。
### 8.1 输入方向
```text
BidiAppend request
-> Transport 解 Connect body
-> Dialect.decodeClientMessage
-> Input
-> transition(state, input)
```
`Dialect.decodeClientMessage` 的映射:
```text
run_request.user_message
-> Input.Start 或 Input.UserMessage
exec_client_message.tool_result
-> Input.ToolResult
interaction_response
-> Input.UserMessage 或对应 ClientInput
conversation_action.cancel
-> Input.Cancel
```
Bidi 的 `request_id``append_seqno``conversation_id` 属于 Transport/Dialect 的关联信息,不进入模型消息文本。
### 8.2 输出方向
```text
transition.emit: ResponseEvent
-> Dialect.encodeServerEvent
-> AgentServerMessage
-> RunSSE writer
```
推荐映射:
```text
ResponseEvent.Start
-> 不写协议消息;只初始化本次流的内部关联状态
ResponseEvent.TextStart / ResponseEvent.TextEnd
-> 不写协议消息;Cursor 由 text_delta 表达可见文本
ResponseEvent.TextDelta
-> interaction_update.text_delta
ResponseEvent.ThinkingDelta
-> interaction_update.thinking_delta
ResponseEvent.ThinkingEnd
-> interaction_update.thinking_completed
ResponseEvent.ToolCallStart
-> interaction_update.tool_call_started
ResponseEvent.ToolCallDelta
-> interaction_update.tool_call_delta
ResponseEvent.ToolCallEnd
-> interaction_update.tool_call_completed
Command.CallClient
-> exec_server_message
ResponseEvent.Done(stop)
-> interaction_update.turn_ended
-> RunSSE end-stream
ResponseEvent.Error
-> 协议错误消息或 RunSSE 结构化错误
-> RunSSE end-stream
```
这里的映射只是协议表达方式改变,事件的文本、工具调用 ID、工具名、参数、停止原因和响应 ID 都必须保留。
### 8.3 LLM 流与 RunSSE 的时序
```text
RunSSE 建立
-> 注册 request_id
-> 接收 Start
-> 写出 TextDelta / ThinkingDelta
-> 写出 ToolCallDelta
-> 写出工具请求
-> 等待 BidiAppend 工具结果
-> 继续下一次 LLM 流
-> 写出 Done
-> 关闭 RunSSE
```
RunSSE writer 必须顺序写出事件。不能让多个 goroutine 直接写同一个连接;所有输出先进入一个有序发送队列。
heartbeat 属于 Transport,不属于 LLM `ResponseEvent`,也不进入 `messages`
## 9. Dialect 的边界
Dialect 只包含三类代码:
### 9.1 解码
将 Cursor protobuf 转换为内部输入:
```text
decodeBidiAppend(request) -> InputEnvelope
decodeExecClientMessage(message) -> ToolResult
decodeInteractionResponse(message) -> ClientInput
```
### 9.2 编码
将原生 LLM 事件和客户端命令转换为 Cursor protobuf
```text
encodeResponseEvent(event) -> AgentServerMessage
encodeClientCommand(command) -> ExecServerMessage / InteractionQuery
```
### 9.3 协议关联
Dialect 可以补充协议必需的:
- `request_id`
- `conversation_id`
- `interaction_id`
- `turn_seq`
- `exec_id`
- `tool_call_id`
- Bidi 的 `append_seqno`
Dialect 不可以做以下事情:
- 拼接或修改模型历史。
- 根据文本猜测工具调用。
- 决定是否重试 LLM。
- 执行工具。
- 保存 Loop 状态。
- 把 delta 合并成另一套公共事件。
如果以后增加 WebSocket 方言,只需新增一个编码/解码实现,Loop、LLM 和 Client 不变。
## 10. 工具调用和客户端等待
模型完成一次响应并返回 `StopReasonToolUse` 时:
```text
AssistantMessage(ToolCall)
-> append 到 messages
-> command = CallClient
```
`CallClient` 是一个可持久化的普通数据:
```text
CallClient {
operationID
toolCall {
id
name
arguments
}
}
```
Dialect 将其变成 `exec_server_message`,Transport 发送给客户端。此时 Loop 状态是 `WaitingClient`
客户端返回结果后:
```text
ToolResultMessage
-> append 到 messages
-> 当前工具调用标记完成
-> 仍有未完成工具调用时,command = CallClient(下一项)
-> 全部完成时,清空 waiting,重新 build RequestMessagescommand = CallLLM
```
工具结果只能通过 `ToolCallID` 关联,不能根据消息顺序猜测对应关系。
一条 `AssistantMessage` 可以包含多个 `ToolCall`。第一版固定按该消息中 `Content` 的顺序逐个派发;一个工具结果提交完成后才派发下一个。这样工具结果追加到 `messages` 的顺序是确定的,连续 LLM 请求的前缀也稳定。未来若必须并行执行,也必须等全部结果完成后按原始工具调用顺序统一追加,不能按到达顺序追加。
## 11. 幂等和重试
Loop 的幂等规则如下:
### 11.1 输入去重
每个输入带有 `inputSeq` 或外部稳定 ID
```text
inputID = requestID + appendSeqno
```
已经提交过的输入再次到达时,返回之前记录的 Transition 结果,不重复执行工具或追加消息。
### 11.2 工具调用去重
`operationID``conversationID + turnID + toolCallID` 生成。
执行前查询提交记录:
```text
已完成 -> 直接返回已保存的 ToolResultMessage
执行中 -> 等待原操作结果
未执行 -> 执行一次
```
### 11.3 LLM 重试
LLM 重试必须使用:
```text
同一个 callID
相同的 messagesHash
完全相同的 RequestMessages 序列化结果
```
不合并两次响应,不把第一次的半截文本和第二次的文本拼接起来。只有一个完整、合法的 `Done` 结果可以提交到 `messages`
如果某次响应已经提交,再收到同一 `callID` 的重复流,整次流丢弃,不追加第二条助手消息。
### 11.4 同一会话的顺序
同一个 `conversationID` 的输入和 LLM 流事件必须串行进入 `transition`。这是执行顺序,不是另一套业务状态机:
```text
conversation_id
-> 一条顺序执行链
-> transition
-> SQLite version compare-and-swap
```
可在进程内用按 `conversationID` 的短锁或任务队列减少竞争;SQLite 的 `version` 是最终裁决。任何提交发现版本已变化,就重新加载状态并重新处理尚未提交的输入。不能让两个 LLM 流同时向同一个会话追加消息。
## 12. 前缀缓存保证
每次 LLM 请求满足:
```text
request[n].Messages = request[n-1].Messages + newlyCommittedMessages
```
禁止:
- 修改历史消息内容。
- 合并相邻消息。
- 把多条 tool result 重排。
- 在旧消息中插入新的 context。
- 每次重放重新生成随机 ID 或时间戳。
- 把流式 delta 直接写入历史。
动态 prompt 和 Tools 每次可以重新 build,但 `Messages` 的字节序列必须只增加,不回退、不重写。
这里的“前缀”指每个已存在消息的语义内容和确定性序列化都不变,新增消息只排在末尾。完整 HTTP JSON body 本身不要求是字节前缀,因为 `SystemPrompt``Tools` 可以在本次请求重新 build;供应商适配器的责任是确保既有消息对应的请求片段不发生变化。
建议在每次 `CallLLM` 记录:
```text
messagesHash
messageCount
lastMessageHash
serializedRequestHash
```
测试必须确认连续请求满足前缀关系,而不是只比较消息数量。
## 13. 持久化边界
Store 至少提供以下能力:
```text
load(conversationID) -> ConversationState
loadInputResult(inputID) -> PreviousCommit?
commitInput(inputID, beforeVersion, nextState) -> CommitResult
saveLLMCall(callID, exactRequest, requestHash, status)
saveClientOperation(operationID, request, result)
```
提交顺序固定:
```text
1. transition 得到新状态和 command
2. 对会话状态有变化时,在一个事务中保存 state、inputID 和调用记录
3. 对 CallLLM,先保存 exactRequest 和 callID,再打开上游流
4. 对 CallClient,先保存 waiting 和 operationID,再写出客户端请求
5. LLM delta 实时写入 RunSSE,但不提交到 ConversationState
6. 外部结果作为新的 Input 再进入 transition
```
流中的 delta 默认不落入会话历史,也不需要进入 SQLite outbox。可以单独保存为诊断日志,但不能把诊断日志当作下一次 LLM 的 `Messages`
`AssistantMessage``ToolResultMessage``UserMessage`、未完成的 `CallLLM` 和未完成的 `CallClient` 必须在进程重启后可恢复。RunSSE 连接和未完成 delta 不需要持久化;重连时可以重新打开当前 turn 的 RunSSE,恢复调用后重新流式展示。模型历史不受影响,因为旧 delta 从未提交。
### 13.1 SQLite 最小表结构
第一版不需要事件溯源库。五张表足够:
```text
conversations
conversation_id primary key
version integer -- 每次已提交状态递增
status text
turn_id text
messages_json blob -- 按顺序的 llm.Message 数组
waiting_json blob nullable -- 未完成 CallClient
updated_at_ms integer
input_commits
conversation_id
input_id
committed_version
result_json blob -- 重复 Bidi 输入的返回结果
primary key (conversation_id, input_id)
llm_calls
call_id primary key
conversation_id
request_json blob -- exactRequest
request_hash text
messages_hash text
status text -- planned, streaming, committed, failed, canceled
assistant_hash text nullable
client_operations
operation_id primary key
conversation_id
turn_id
tool_call_id
tool_index integer
request_json blob
result_json blob nullable
status text -- planned, sent, completed, canceled
stream_diagnostics
call_id
event_index
event_json blob
primary key (call_id, event_index)
```
`stream_diagnostics` 是可选表,只用于调试和抓包分析。它绝不能被读取后回填成 `messages`
每次提交使用 SQLite 事务和乐观版本条件:
```text
update conversations
set version = version + 1, ...
where conversation_id = ? and version = ?
```
没有更新到一行说明发生竞争;重新加载后再处理。`input_commits` 的唯一键负责 Bidi 重放去重,`llm_calls.call_id``client_operations.operation_id` 分别负责 LLM 与工具调用去重。
## 14. 取消、断线和错误
### 14.1 用户取消
```text
conversation_action.cancel
-> Input.Cancel
-> transition 返回 Canceled
-> cancel LLM stream / client operation
-> 发布协议取消事件
-> 关闭 RunSSE
```
### 14.2 RunSSE 断线
RunSSE 断开不等于用户取消。只停止当前发送连接,Loop 继续运行一段重连宽限时间。Bidi 仍可提交工具结果或取消命令。
### 14.3 LLM 流错误
```text
Recv error
-> 生成 ResponseEvent.Error
-> 丢弃 pendingResponse
-> 保存 call failure
-> 根据策略 Failed 或重新发起同一 callID
```
不得把网络错误文本写成正常 `AssistantMessage`
### 14.4 客户端工具错误
工具失败仍然生成 `ToolResultMessage{IsError: true}`,追加后交给下一次 LLM。只有协议连接错误、取消或系统不可恢复错误才终止 Loop。
## 15. 推荐执行时序
```text
1. Transport 收到 RunSSE 或 BidiAppend
2. Dialect 验证 request_id、seqno 和 protobuf oneof
3. Store 加载 conversation 的 ConversationState
4. Dialect 将客户端消息解码成 Input
5. transition(state, input)
6. Store 在同一事务中提交新的 state、inputID 和必要的调用记录
7. 将 `ResponseEvent` 编码后按顺序写入 RunSSE;delta 不写入模型历史
8. 执行 commandCallClient 发出请求后返回等待态
9. LLM 流逐事件回到第 5 步
10. 客户端工具结果回到第 4 步
11. Done(stop) 后写出 turn ended,并关闭 RunSSE
```
## 16. 最小接口集合
实现第一版只需要这些接口:
```text
type LLM interface {
Call(context, RequestMessages) -> ResponseStream
}
type Dialect interface {
DecodeBidi(bytes) -> InputEnvelope
EncodeResponse(ResponseEvent, ProtocolContext) -> AgentServerMessage
EncodeClientCommand(Command, ProtocolContext) -> AgentServerMessage
}
type Store interface {
Load(conversationID) -> ConversationState
FindInputCommit(conversationID, inputID) -> PreviousCommit?
CommitInput(inputID, expectedVersion, nextState) -> CommitResult
SaveLLMCall(callID, exactRequest, hashes, status)
SaveClientOperation(operationID, request, status)
}
type Transport interface {
ReceiveBidi()
OpenRunSSE()
Send(AgentServerMessage)
}
```
客户端工具结果由 Bidi 适配器解码并再次送入 `run`,不需要一个阻塞式的 `Client.Execute` 服务端接口。接口名称可以调整,但职责不能跨层移动。
## 17. 测试要求
### 17.1 Loop 纯函数测试
给定相同的 `state + input`,必须得到完全相同的:
- 新状态。
- `emit` 顺序。
- `command` 内容。
- `messagesHash`
覆盖:文本流、思考流、工具调用流、正常完成、长度停止、错误、中断、重复输入。
### 17.2 前缀测试
连续三次调用的 `Messages` 必须满足:
```text
M1 是 M2 的严格前缀
M2 是 M3 的严格前缀
```
测试序列化后的消息字节,而不是只比较对象字段。
### 17.3 Dialect 测试
每一种协议消息都测试:
```text
protobuf -> Input
Input/ResponseEvent -> protobuf
```
重点验证 ID、seqno、工具参数、错误码、停止原因和 oneof 分支没有丢失。
### 17.4 流集成测试
使用假的 `ResponseStream` 依次返回:
```text
Start -> TextDelta* -> ToolCall* -> Done
```
断言:
- 每个 delta 都按顺序发到 RunSSE。
- 只有 Done 后才追加 AssistantMessage。
- 工具结果到达后才启动下一次 LLM。
- 重复 Done 不产生第二条消息。
## 18. 第一版落地顺序
1. 固定 `llm.RequestMessages``ResponseEvent``ResponseStream` 为核心契约。
2. 实现 `ConversationState``Input``Command` 和纯函数 `transition`
3. 实现 `consumeLLM`,验证流事件和 `pendingResponse` 聚合。
4. 实现 `CallClient` 命令、工具请求发送和 `ToolResultMessage` 回传。
5. 实现 Dialect 的 Bidi 解码和 RunSSE 编码。
6. 加入 Store 的状态提交、输入去重和工具操作去重。
7. 最后接入真实 Connect transport、heartbeat、重连和取消。
完成后,新增一种客户端协议只需要新增 Dialect;新增一种 LLM 供应商只需要新增 LLM 适配器;新增一种工具只需要新增工具能力描述和对应的客户端协议映射。Loop 本身不需要增加状态分支。