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

24 KiB
Raw Blame History

Loop + Dialect 完整落地方案

1. 设计目标

这是一个从零设计的服务端方案,不依赖当前项目的服务端实现。

目标只有四个:

  1. Loop 的状态决策是纯函数,外层运行器用递归驱动它,直到得到最终答案。
  2. LLM 使用原生请求和原生流事件,不再造一套平行的模型消息结构。
  3. Cursor 的 Bidi、RunSSE、protobuf 只存在于 Dialect 和 Transport 中。
  4. messages 永远是完整、顺序固定、只追加的历史,保证前缀缓存稳定。

模型调用是无状态的。每一次调用都发送完整的 RequestMessages,而不是向模型发送“上一次请求的差异”。

2. 顶层结构

代码目录只保留四个模块:

server/
  loop/        纯函数状态转换、消息历史和下一步决定
  llm/         原生请求、原生响应流、供应商适配器
  transport/   Connect、Bidi、RunSSE、CursorDialect、运行器
  store/       SQLite 状态、调用记录、输入和工具去重

CursorDialecttransport/ 里的协议翻译文件,不单独形成目录。客户端是远端能力:服务端向它发工具请求,它经 Bidi 返回工具结果;因此也不在服务端拆出 client/ 模块。

store/ 只是基础设施适配器:它保存状态和提交记录,不决定下一步动作。

依赖方向固定为:

transport -> loop
transport -> llm
transport -> store

CursorDialect (inside transport) -> loop / llm

transport/runner 是很薄的组装代码:它执行 Command,把外部结果再送回 looploop 不依赖 protobuf、HTTP、SSE、连接对象、Store、客户端或具体 LLM 供应商。

3. 三类核心数据

3.1 模型请求

直接使用 internal/backend/cursor/llm/request.go 中的结构:

RequestMessages {
  SystemPrompt
  Messages []Message
  Tools []ToolDefinition
}

这里有一个重要边界:

  • Messages 是会话历史,必须只追加。
  • SystemPromptTools 是本次请求构建出来的请求部分。
  • 前缀缓存约束只针对 Messages
  • 不能通过合并、去重、重排或“修正上一条消息”来构建历史。

每次请求的模型上下文都是:

RequestMessages {
  SystemPrompt: buildPrompt(input, state)
  Messages:     state.messages
  Tools:        buildTools(input, state)
}

buildPromptbuildTools 可以每次重新计算,但不能修改 state.messages

3.2 模型响应

直接使用 internal/backend/cursor/llm/response.go 中的结构:

ResponseEvent {
  Start
  TextStart / TextDelta / TextEnd
  ThinkingStart / ThinkingDelta / ThinkingEnd
  ToolCallStart / ToolCallDelta / ToolCallEnd
  Done
  Error
}

完整响应使用 AssistantMessage。工具结果使用 ToolResultMessage。用户输入使用 UserMessage

internal/backend/cursor/llm/stream.go 中的接口是 LLM 边界:

ResponseStream.Recv(context) -> (ResponseEvent, error)

LLM 适配器可以将 OpenAI、Anthropic、Gemini 或其他供应商的响应转换为这些原生中间结构,但不能把供应商私有的流格式泄漏到 Loop。

3.3 Loop 输入

Loop 只接收有语义的输入,不接收网络数据:

Input =
  Start {
    userMessage: UserMessage
    context:     []ContextSupplement
  }
| LLMEvent {
    callID: string
    event:  ResponseEvent
  }
| ToolResult {
    message: ToolResultMessage
  }
| UserMessage {
    message: UserMessage
  }
| Cancel {
    reason: string
  }

BidiAppend 解码后只能生成这些输入。Loop 不需要知道输入原来来自 Bidi、HTTP 还是测试代码。

上下文补充如果要被模型看到,必须转换成新的消息追加到历史;不能回写旧消息:

旧 messages + 新 UserMessage(context supplement)

4. 状态:已提交部分与正在生成部分

运行中的状态分为两部分:

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 是唯一的模型历史:

  • 只能在完整的 UserMessageAssistantMessageToolResultMessage 完成后追加。
  • 已经追加的消息永远不变。
  • 顺序永远按照发生顺序排列。
  • 不使用 map 作为模型消息容器。
  • 不在重放时重新生成时间戳、随机 ID 或不稳定字段。
  • 工具调用的签名、思考签名和供应商响应 ID 原样保留。

4.2 pendingResponse

pendingResponse 是本次 LLM 流的临时聚合器,不属于模型历史:

PendingResponse {
  callID
  partialMessage
  openContentBlocks
  openToolCalls
  usage
}

它只接收流中的增量事件。只有 Done 才能把完整的 AssistantMessage 追加到 messages

pendingResponse 只存在于内存。每个 delta 都可以被实时发送给 RunSSE,也可以写入诊断日志,但不会被提交到 ConversationState

如果进程在 Done 前重启:

丢弃 pendingResponse
保留本次 callID、requestHash 和调用状态
从最后一份已提交的 ConversationState 重试相同请求

不恢复半截文本,不把旧 delta 与新响应拼接,也不把旧 delta 当作模型消息。这样即使上游流不可续传,模型历史仍然一致。

5. 纯函数转换接口

Loop 的核心函数固定为:

transition(state, input) -> Transition

返回值:

Transition {
  state
  emit      []llm.ResponseEvent
  command   Command
}

emit 使用 LLM 原生 ResponseEvent,不创建 AssistantTextDeltaToolCallOutput 等第二套事件。

Command 只有几种:

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 的递归规则

核心判断是纯函数:

transition(runtimeState, input) -> Transition

它不执行 I/O,也不自行取得下一条输入。递归发生在外层运行器:

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 是外层执行器:

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 提交 ToolResultMessageTransport 再次调用 run(loadedState, ToolResult)

这里不是从旧调用栈继续等待。工具结果是一个新的外部输入,也是递归的下一层。

实现时可以使用异步尾递归、trampoline 或任务调度器避免实际调用栈无限增长,但不能把业务逻辑改成一个可随意修改历史的可变状态循环。

7. LLM 流的处理

7.1 启动一次调用

CallLLM 携带完整请求和请求快照信息:

CallLLM {
  callID
  request
  messagesHash
}

messagesHash 是发送前按消息顺序对完整 Messages 序列做的稳定哈希,用来确认重试时没有改变历史。重试实际使用已保存的 exactRequest,不重新 build prompt、tools 或 messages。

执行器:

consumeLLM(state, command) {
  stream = llm.call(command.request)
  return readLLM(state, command.callID, stream)
}

7.2 逐个接收事件

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 各事件的状态变化

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

无论模型返回多少个 deltamessages 最终只追加一条完整的 AssistantMessage

ResponseEvent.Partial 是 LLM 适配器提供的当前累计视图。Loop 可以用它校验 pendingResponse 或供 RunSSE 重连时显示,但不能用它覆盖、修改或合并任何已提交的 messages。唯一允许提交到 messages 的助手响应来自 ResponseEvent.Done.Message

8. 外层流协议的对接

外层协议分两层:

Transport
  负责连接、读写、framing、断开、heartbeat

Dialect
  负责 protobuf 消息与原生语义结构之间的翻译

Loop 只产生 ResponseEventCommand,不直接写 RunSSE。

8.1 输入方向

BidiAppend request
  -> Transport 解 Connect body
  -> Dialect.decodeClientMessage
  -> Input
  -> transition(state, input)

Dialect.decodeClientMessage 的映射:

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_idappend_seqnoconversation_id 属于 Transport/Dialect 的关联信息,不进入模型消息文本。

8.2 输出方向

transition.emit: ResponseEvent
  -> Dialect.encodeServerEvent
  -> AgentServerMessage
  -> RunSSE writer

推荐映射:

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 的时序

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 转换为内部输入:

decodeBidiAppend(request) -> InputEnvelope
decodeExecClientMessage(message) -> ToolResult
decodeInteractionResponse(message) -> ClientInput

9.2 编码

将原生 LLM 事件和客户端命令转换为 Cursor protobuf

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 时:

AssistantMessage(ToolCall)
  -> append 到 messages
  -> command = CallClient

CallClient 是一个可持久化的普通数据:

CallClient {
  operationID
  toolCall {
    id
    name
    arguments
  }
}

Dialect 将其变成 exec_server_message,Transport 发送给客户端。此时 Loop 状态是 WaitingClient

客户端返回结果后:

ToolResultMessage
  -> append 到 messages
  -> 当前工具调用标记完成
  -> 仍有未完成工具调用时,command = CallClient(下一项)
  -> 全部完成时,清空 waiting,重新 build RequestMessagescommand = CallLLM

工具结果只能通过 ToolCallID 关联,不能根据消息顺序猜测对应关系。

一条 AssistantMessage 可以包含多个 ToolCall。第一版固定按该消息中 Content 的顺序逐个派发;一个工具结果提交完成后才派发下一个。这样工具结果追加到 messages 的顺序是确定的,连续 LLM 请求的前缀也稳定。未来若必须并行执行,也必须等全部结果完成后按原始工具调用顺序统一追加,不能按到达顺序追加。

11. 幂等和重试

Loop 的幂等规则如下:

11.1 输入去重

每个输入带有 inputSeq 或外部稳定 ID

inputID = requestID + appendSeqno

已经提交过的输入再次到达时,返回之前记录的 Transition 结果,不重复执行工具或追加消息。

11.2 工具调用去重

operationIDconversationID + turnID + toolCallID 生成。

执行前查询提交记录:

已完成 -> 直接返回已保存的 ToolResultMessage
执行中 -> 等待原操作结果
未执行 -> 执行一次

11.3 LLM 重试

LLM 重试必须使用:

同一个 callID
相同的 messagesHash
完全相同的 RequestMessages 序列化结果

不合并两次响应,不把第一次的半截文本和第二次的文本拼接起来。只有一个完整、合法的 Done 结果可以提交到 messages

如果某次响应已经提交,再收到同一 callID 的重复流,整次流丢弃,不追加第二条助手消息。

11.4 同一会话的顺序

同一个 conversationID 的输入和 LLM 流事件必须串行进入 transition。这是执行顺序,不是另一套业务状态机:

conversation_id
  -> 一条顺序执行链
  -> transition
  -> SQLite version compare-and-swap

可在进程内用按 conversationID 的短锁或任务队列减少竞争;SQLite 的 version 是最终裁决。任何提交发现版本已变化,就重新加载状态并重新处理尚未提交的输入。不能让两个 LLM 流同时向同一个会话追加消息。

12. 前缀缓存保证

每次 LLM 请求满足:

request[n].Messages = request[n-1].Messages + newlyCommittedMessages

禁止:

  • 修改历史消息内容。
  • 合并相邻消息。
  • 把多条 tool result 重排。
  • 在旧消息中插入新的 context。
  • 每次重放重新生成随机 ID 或时间戳。
  • 把流式 delta 直接写入历史。

动态 prompt 和 Tools 每次可以重新 build,但 Messages 的字节序列必须只增加,不回退、不重写。

这里的“前缀”指每个已存在消息的语义内容和确定性序列化都不变,新增消息只排在末尾。完整 HTTP JSON body 本身不要求是字节前缀,因为 SystemPromptTools 可以在本次请求重新 build;供应商适配器的责任是确保既有消息对应的请求片段不发生变化。

建议在每次 CallLLM 记录:

messagesHash
messageCount
lastMessageHash
serializedRequestHash

测试必须确认连续请求满足前缀关系,而不是只比较消息数量。

13. 持久化边界

Store 至少提供以下能力:

load(conversationID) -> ConversationState
loadInputResult(inputID) -> PreviousCommit?
commitInput(inputID, beforeVersion, nextState) -> CommitResult
saveLLMCall(callID, exactRequest, requestHash, status)
saveClientOperation(operationID, request, result)

提交顺序固定:

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

AssistantMessageToolResultMessageUserMessage、未完成的 CallLLM 和未完成的 CallClient 必须在进程重启后可恢复。RunSSE 连接和未完成 delta 不需要持久化;重连时可以重新打开当前 turn 的 RunSSE,恢复调用后重新流式展示。模型历史不受影响,因为旧 delta 从未提交。

13.1 SQLite 最小表结构

第一版不需要事件溯源库。五张表足够:

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 事务和乐观版本条件:

update conversations
set version = version + 1, ...
where conversation_id = ? and version = ?

没有更新到一行说明发生竞争;重新加载后再处理。input_commits 的唯一键负责 Bidi 重放去重,llm_calls.call_idclient_operations.operation_id 分别负责 LLM 与工具调用去重。

14. 取消、断线和错误

14.1 用户取消

conversation_action.cancel
  -> Input.Cancel
  -> transition 返回 Canceled
  -> cancel LLM stream / client operation
  -> 发布协议取消事件
  -> 关闭 RunSSE

14.2 RunSSE 断线

RunSSE 断开不等于用户取消。只停止当前发送连接,Loop 继续运行一段重连宽限时间。Bidi 仍可提交工具结果或取消命令。

14.3 LLM 流错误

Recv error
  -> 生成 ResponseEvent.Error
  -> 丢弃 pendingResponse
  -> 保存 call failure
  -> 根据策略 Failed 或重新发起同一 callID

不得把网络错误文本写成正常 AssistantMessage

14.4 客户端工具错误

工具失败仍然生成 ToolResultMessage{IsError: true},追加后交给下一次 LLM。只有协议连接错误、取消或系统不可恢复错误才终止 Loop。

15. 推荐执行时序

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. 最小接口集合

实现第一版只需要这些接口:

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 必须满足:

M1 是 M2 的严格前缀
M2 是 M3 的严格前缀

测试序列化后的消息字节,而不是只比较对象字段。

17.3 Dialect 测试

每一种协议消息都测试:

protobuf -> Input
Input/ResponseEvent -> protobuf

重点验证 ID、seqno、工具参数、错误码、停止原因和 oneof 分支没有丢失。

17.4 流集成测试

使用假的 ResponseStream 依次返回:

Start -> TextDelta* -> ToolCall* -> Done

断言:

  • 每个 delta 都按顺序发到 RunSSE。
  • 只有 Done 后才追加 AssistantMessage。
  • 工具结果到达后才启动下一次 LLM。
  • 重复 Done 不产生第二条消息。

18. 第一版落地顺序

  1. 固定 llm.RequestMessagesResponseEventResponseStream 为核心契约。
  2. 实现 ConversationStateInputCommand 和纯函数 transition
  3. 实现 consumeLLM,验证流事件和 pendingResponse 聚合。
  4. 实现 CallClient 命令、工具请求发送和 ToolResultMessage 回传。
  5. 实现 Dialect 的 Bidi 解码和 RunSSE 编码。
  6. 加入 Store 的状态提交、输入去重和工具操作去重。
  7. 最后接入真实 Connect transport、heartbeat、重连和取消。

完成后,新增一种客户端协议只需要新增 Dialect;新增一种 LLM 供应商只需要新增 LLM 适配器;新增一种工具只需要新增工具能力描述和对应的客户端协议映射。Loop 本身不需要增加状态分支。