refactor: 0.1.0-beta

This commit is contained in:
leokun
2026-08-13 22:01:11 +08:00
parent a3ec2a0dfc
commit 3e7a15017d
401 changed files with 8696 additions and 182189 deletions
+435
View File
@@ -0,0 +1,435 @@
# BidiAppend / RunSSE 原服务架构推断
本文根据实际抓包、已提取的 protobuf 定义以及同一 `conversation_id` 下的消息关联关系,分析 Cursor Agent 原服务采用的通信技术、运行方式和可能的服务架构。
本文只描述协议事实和架构推断,不描述当前项目的实现方案。
## 1. 分析样本
本次分析使用以下会话:
```text
conversation_id: 9b31772c-35fe-4b51-a862-c177749854af
```
最初分析快照包含两个独立 turn;随后该会话又新增第三个 turn。以下表格保留最初两轮的详细帧统计,第三轮的 KV 细节在 KV 专项文档中单独记录:
| Turn | `request_id` | RunSSE 时长 | RunSSE 帧数 | 上行消息 |
| --- | --- | ---: | ---: | --- |
| 1 | `2faaa6b5-6ad7-4428-85f7-8cfc0cb3e52e` | 24,128 ms | 24 | 1 个 `run_request`、4 个 heartbeat、9 个 KV 响应 |
| 2 | `feac27a2-3baf-4153-b009-2809dc9d4cf2` | 3,294 ms | 24 | 1 个 `run_request`、8 个 KV 响应 |
两个 turn 使用相同的 `conversation_id`,但分别使用新的 `request_id`。这说明 `conversation_id` 表示跨 turn 的持久会话,而 `request_id` 表示一次活动请求流或一次 turn 的运行实例。
## 2. 核心结论
该通信方式可以概括为:
> Connect RPC + Protobuf 实现的 split-duplex streaming,上层运行 request 维度的 Agent Actor / Workflow 状态机。
它不是 WebSocket,也不是标准 gRPC 双向流。虽然 RunSSE 响应头使用 `text/event-stream`,但正文不是传统 SSE 的 `data:` 文本事件,而是 Connect 的二进制流式帧。
从应用语义看,它将逻辑双向流拆成两个方向相反的 HTTP 通道:
- `BidiAppend`:客户端通过多个 unary RPC 向服务端发送命令、心跳和本地执行结果。
- `RunSSE`:服务端通过一条 server-streaming RPC 向客户端发送增量事件、工具请求、状态 checkpoint 和流终态。
两条通道通过相同的 `request_id` 关联,共同构成应用层的双向通信。
## 3. 传输技术
### 3.1 Connect RPC
抓包中的请求头包含:
```text
Connect-Protocol-Version: 1
User-Agent: connect-es/1.6.1
```
这说明桌面客户端的协议调用层使用 Connect-ES。它很可能运行在 Cursor 的 Electron / VS Code JavaScript 环境中。
`BidiAppend` 使用:
```text
Content-Type: application/proto
```
这是一个 protobuf unary RPC。每次请求只追加一条 `AgentClientMessage`,服务端返回空的 `BidiAppendResponse` 作为接收确认。
`RunSSE` 请求使用:
```text
Content-Type: application/connect+proto
Connect-Accept-Encoding: gzip
Connect-Content-Encoding: gzip
```
响应使用:
```text
Content-Type: text/event-stream
Connect-Content-Encoding: gzip
```
`text/event-stream` 在这里是兼容性响应类型,实际正文仍使用 Connect 二进制 envelope。因此不能使用标准 EventSource 文本解析器处理该响应。
### 3.2 Connect 流式帧
每个 RunSSE 消息使用以下帧结构:
```text
+------------+----------------------+--------------------+
| flags: 1B | length: uint32 BE | payload: length B |
+------------+----------------------+--------------------+
```
已观察到的 flags
| flags | 功能 |
| --- | --- |
| `0x00` | 未压缩的 protobuf 数据帧 |
| `0x01` | 压缩的数据帧 |
| `0x02` | EndStream 终态帧 |
小型 heartbeat 和 token 增量通常使用 `0x00`,体积较大的 KV 或 checkpoint 消息可能使用 `0x01`。两个样本的最后一帧都是 `0x02`
底层连接可以运行在 HTTP/1.1 chunked response 或 HTTP/2 stream 上。当前抓包不足以确定客户端到原服务实际使用了哪一个 HTTP 版本。
## 4. 一次 Turn 的运行时序
抓包顺序表明客户端通常先建立 RunSSE,再通过 BidiAppend 发送 `run_request`。这样可以在启动 Agent Run 之前准备好下行订阅,避免遗漏早期事件。
```mermaid
sequenceDiagram
participant Client as Cursor Client
participant Gateway as API Gateway
participant Actor as Request Actor
participant Provider as Model Provider
Client->>Gateway: RunSSE(request_id)
Gateway->>Actor: Subscribe(request_id)
Client->>Gateway: BidiAppend(run_request)
Gateway->>Actor: Start turn
Actor->>Provider: Start model call
Provider-->>Actor: Thinking / token / tool deltas
Actor-->>Client: AgentServerMessage stream
Actor-->>Client: KV / Exec / Interaction request
Client->>Actor: BidiAppend(result, append_seqno)
Actor->>Provider: Resume with external result
Actor-->>Client: Conversation checkpoint
Actor-->>Client: EndStream
```
完整生命周期为:
1. 客户端生成本次 turn 的 `request_id`
2. 客户端使用 `BidiRequestId` 建立 RunSSE 下行流。
3. 客户端通过 BidiAppend 发送 `run_request`
4. 服务端启动模型调用并持续发送 `thinking_delta``text_delta``token_delta` 和 step 状态。
5. 服务端需要客户端能力时,通过 RunSSE 发送 KV、Exec 或 Interaction 请求。
6. 客户端执行本地操作,并通过 BidiAppend 返回对应结果。
7. 服务端根据外部结果继续模型循环,或者进入 turn 收口阶段。
8. 服务端同步 checkpoint 及其 blob。
9. 服务端发送 EndStream,结束本次 `request_id` 对应的流。
## 5. 上行顺序与幂等语义
`BidiAppendRequest.append_seqno` 是同一个 `request_id` 内的有序序号。
第一个 turn 中观察到:
```text
run_request append_seqno = 0(字段使用默认值)
client_heartbeat append_seqno = 1..4
kv_client_message append_seqno = 5..13
```
KV 响应对应的 HTTP 请求在抓包记录中并不完全按照序号排列,说明客户端可能并发发起多个 BidiAppend 请求。服务端必须按 `append_seqno` 排序、串行处理或拒绝过期消息,不能依赖 HTTP 请求的到达顺序。
因此 `append_seqno` 至少承担以下功能:
- 确定同一个请求流内的命令顺序。
- 识别重复提交或重试。
- 在多个并发 unary 请求之间恢复确定性处理顺序。
它不是整个 conversation 的全局序号。新的 `request_id` 可以重新从较小的序号开始。
## 6. 标识符与状态边界
### 6.1 `conversation_id`
`conversation_id` 是跨 turn 的持久会话标识。它关联历史消息、checkpoint、token 状态、模式以及 workspace 元数据。
样本中的第二个 `run_request` 已携带第一轮产生的 `conversation_state`,证明 conversation 状态会跨 `request_id` 延续。
### 6.2 `request_id`
`request_id` 是活动流、一次 turn 或一次运行尝试的路由键。它同时出现在:
- RunSSE 订阅请求中。
- BidiAppend 外层请求中。
- `X-Request-Id` HTTP 请求头中。
- 本次 turn 的服务端事件和客户端结果关联关系中。
服务端需要以 `request_id` 找到正在运行的 Actor、事件 backlog、订阅者以及待处理的工具调用。
### 6.3 `run_id`
本次两个样本中的 `run_id` 与各自的 `request_id` 相同,但协议中它们是独立字段。架构设计不应假定两者永久等值:
- `request_id` 偏向传输和活动流路由。
- `run_id` 偏向 Agent 执行实例。
### 6.4 KV `id`
`KvServerMessage.id``KvClientMessage.id` 构成一次服务端到客户端 RPC 的关联键。它与 `append_seqno` 的职责不同:
- KV `id` 关联某个具体请求和响应。
- `append_seqno` 规定所有上行消息的处理顺序。
## 7. Checkpoint 与 Blob 同步
协议中的 KV 虽然以 Key-Value 命名,但它表达的不是普通配置项或业务数据库。它更接近一个由客户端提供的内容寻址 Blob StoreContent-Addressable StoreCAS),用于保存和恢复 conversation checkpoint 的组成部分。
### 7.1 KV 消息语义
服务端通过 RunSSE 发起 KV 操作:
| 消息 | 参数 | 功能 |
| --- | --- | --- |
| `get_blob_args` | `blob_id` | 要求客户端返回此前保存的 Blob。 |
| `set_blob_args` | `blob_id``blob_data` | 要求客户端保存指定 Blob。 |
客户端通过 BidiAppend 返回操作结果:
| 消息 | 参数 | 功能 |
| --- | --- | --- |
| `get_blob_result` | `blob_data``error` | 返回 Blob 内容或读取错误。 |
| `set_blob_result` | 可选 `error` | 确认保存成功,或返回写入错误。 |
KV 消息中存在两类用途不同的 ID:
- `KvServerMessage.id`:本次 KV 操作的临时流水号,客户端使用相同值返回 `KvClientMessage`
- `blob_id`:Blob 内容的稳定地址,用来在 checkpoint 和其他协议消息中引用内容。
对该会话中全部 16 个 `set_blob_args` 进行校验后,每一个 `blob_id` 都精确等于对应 `blob_data` 的 SHA-256。由此可以确认这里使用的是内容寻址,而不是随机生成的 KV key:
```text
blob_id = SHA-256(blob_data)
```
相同内容必然得到相同 `blob_id`,内容发生任何改变都会生成新的 ID。因此 Blob 可以被视为不可变对象,重复写入同一 Blob 也天然具有幂等性。
### 7.2 Blob 表达的内容
Blob 主要承载 conversation checkpoint 中体积较大、可以独立复用的 protobuf 节点,例如:
- 用户消息。
- Thinking、Assistant Message 和 ToolCall 等 conversation step。
- Conversation turn。
- Prompt context usage snapshot。
- Rules、Skills、Subagents、MCP 等大型请求上下文。
- 其他通过 `blob_id``data_blob_id``content_blob_id` 引用的二进制内容。
Checkpoint 本身更接近一个引用清单。会话历史可以形成如下内容寻址对象图:
```text
ConversationStateStructure
└─ turns[]: blob_id
└─ ConversationTurnStructure
├─ user_message: blob_id
└─ steps[]: blob_id
├─ ThinkingMessage
├─ AssistantMessage
└─ ToolCall
```
顶层 checkpoint 不必反复内嵌完整历史,只需要保存根引用。Turn Blob 再引用 UserMessage Blob 和多个 Step Blob。这种结构类似一棵由 SHA-256 连接的不可变 Merkle DAG。
### 7.3 写入与读取流程
Turn 结束或状态发生重要变化时,Blob 写入流程为:
1. 服务端将用户消息、conversation step 和 turn 等节点分别序列化。
2. 服务端对每个序列化结果计算 SHA-256,得到 `blob_id`
3. 服务端通过 RunSSE 发送 `set_blob_args`
4. 客户端保存 Blob,并通过 BidiAppend 返回 `set_blob_result`
5. 必要 Blob 全部确认后,服务端发送引用这些 Blob 的 `conversation_checkpoint_update`
6. 服务端完成本次 turn 并发送 EndStream。
下一轮恢复状态时,Blob 读取流程通常为:
1. 客户端将上一轮 checkpoint 随 `run_request` 发回。
2. 服务端读取 checkpoint 和 `request_context_parts` 中的 Blob 引用。
3. 服务端按需通过 RunSSE 发送 `get_blob_args` 请求自己当前缺少的内容。
4. 客户端通过 BidiAppend 返回 `get_blob_result`
5. 服务端使用已持有或刚读取的 Blob 恢复所需上下文并继续运行。
注意:本次样本中的 `get_blob_args` 实际读取的是 `request_context_parts.mcps_blob_id`,不是 `conversation_state.turns[]` 的 Turn Blob。样本没有直接证明服务端会在每个新 turn 中重新读取历史 Turn Blob;服务端可能已经保存或缓存了这些内容。
### 7.4 当前会话中的证据
第一个 turn
- 服务端通过 RunSSE 发送 9 个 `set_blob_args`
- 客户端通过 BidiAppend 返回 9 个 `set_blob_result`
- 服务端随后发送 `conversation_checkpoint_update`
第二个 turn
- `run_request` 已携带上一轮 `conversation_state`
- 服务端先读取 29,974 字节的 `mcps_blob_id`,客户端返回 `get_blob_result`
- 服务端再发送 7 个 `set_blob_args`,客户端逐一确认。
- 服务端发送新的 checkpoint,然后结束流。
后续第三个 turn
- 服务端读取 59,145 字节的新 `mcps_blob_id`
- 服务端发送 14 个 `set_blob_args`,客户端逐一确认。
这两次 `get_blob_result` 的返回数据都与请求的 `blob_id` 通过 SHA-256 校验一致。
该顺序说明 KV 同步不是与 conversation 无关的后台缓存。它直接参与 checkpoint 提交和 turn 收口:服务端先确保必要内容能够被客户端读取,再发布引用这些内容的状态清单。
### 7.5 KV 的架构作用
该设计提供以下能力:
- **缩小 checkpoint**:主状态只携带引用,不必每轮重复传输完整历史。
- **内容去重**:未变化的消息、step 或 turn 使用相同 SHA-256,只需保存一次。
- **幂等写入**:相同 `blob_id` 永远对应相同内容,重复 `set_blob` 不会产生语义冲突。
- **按需加载**:服务端可以只读取当前恢复流程需要的 Blob;当前样本明确观察到的是 MCP 请求上下文按需读取。
- **跨 Worker 恢复**:新的 Agent Worker 可以根据客户端携带的 checkpoint 和 Blob 恢复上下文,不必依赖原进程内存。
- **避免悬空引用**:客户端确认 Blob 已保存后,服务端才发布最终 checkpoint。
- **客户端状态参与**:本地客户端不仅执行工具,也充当 Agent 会话对象存储协议的一部分。
由此可以确认:
- checkpoint 元数据可以由客户端携带到下一轮。
- 较大的 checkpoint 内容使用内容寻址 Blob 拆分。
- 客户端至少承担 Blob 存取接口或本地 Blob 缓存的角色。
- 服务端会等待必要 Blob 写入得到确认,再完成 checkpoint 和 turn 收口。
KV 的本质因此不是“保存几个键值”,而是客户端侧的 Agent 会话对象存储协议。它与 checkpoint 一起构成“客户端携带状态 + 内容寻址 Blob 同步”的混合状态模型。
抓包不能证明服务端完全不保存这些数据,也不能证明其设计目的包含隐私或数据本地化;它只能证明客户端是状态协议中的实际参与者,而不是薄 UI。
## 8. 原服务的逻辑架构
```mermaid
flowchart LR
Client["Cursor Desktop<br/>UI / Local Tools / KV Blob"]
Gateway["API Gateway<br/>Auth / Route / Affinity"]
Actor["Request Actor<br/>request_id"]
Broker["Stream Broker<br/>Backlog / Subscribers"]
Conversation["Conversation State<br/>conversation_id"]
Provider["Model Provider"]
Client -->|"BidiAppend commands/results"| Gateway
Gateway --> Actor
Actor --> Provider
Provider --> Actor
Actor --> Broker
Broker -->|"RunSSE events"| Gateway
Gateway --> Client
Actor <--> Conversation
```
### 8.1 客户端:本地执行面
客户端负责:
- IDE 和 UI 交互。
- 本地文件、终端、编辑器及其他环境能力。
- 接收服务端的 Exec、KV 和 Interaction 请求。
- 执行本地操作并回传结果。
- 携带 conversation checkpoint,并参与 blob 存取。
- 维护上行 `append_seqno` 和连接心跳。
### 8.2 云端:控制面与推理编排器
服务端负责:
- 接收 `run_request` 并创建或恢复 turn。
- 编排模型 provider 调用。
- 将 provider 增量转换为 `AgentServerMessage`
- 管理等待中的本地工具、KV 和用户交互请求。
- 根据外部结果恢复模型循环。
- 生成 checkpoint,并协调 blob 写入确认。
- 发布终态并结束 RunSSE。
因此原服务更接近 Agent workflow orchestrator,而不是一个简单的聊天补全 API。
### 8.3 Request Actor / Workflow
每个活动 `request_id` 很可能对应一个串行状态实例,可抽象为 Actor 或 workflow
```text
created
-> provider_running
-> waiting_external / awaiting_user
-> provider_running
-> checkpointing
-> completed / failed / canceled
```
BidiAppend 是该 Actor 的 command inboxRunSSE 是该 Actor 的 event stream。这个结构具有明显的 CQRS 形态,但仅凭协议不能断言原服务使用了某个具体 Actor 或事件溯源框架。
## 9. 心跳与连接恢复
第一个 turn 中,客户端约每 5 秒通过 BidiAppend 发送一次 `client_heartbeat`。RunSSE 中也出现服务端 heartbeat。
双向心跳分别解决不同问题:
- 客户端 heartbeat 告诉服务端本地控制通道仍存活。
- 服务端 heartbeat 保持 RunSSE 活跃,并帮助客户端发现下行连接异常。
由于业务事件与 `request_id``append_seqno` 和 checkpoint 分离,协议具备处理短暂重连、请求重试和重复 append 的基础。不过,抓包中尚未出现实际断线重连样本,无法确认原服务的 backlog 保留时长和精确恢复策略。
## 10. 水平扩展约束
BidiAppend 与 RunSSE 是两个独立 HTTP 请求。在多副本部署中,它们可能被负载均衡器分配到不同实例,但必须访问同一个 `request_id` 状态。
因此原服务至少需要满足以下一种条件:
1. API Gateway 按 `request_id` 或会话信息执行粘性路由。
2. 所有实例共享活动流存储、消息 Broker 或分布式 Actor runtime。
3. RunSSE 实例只负责订阅共享事件流,实际 workflow 在独立 worker 中运行。
从协议上无法确定原服务具体采用哪一种。更可能的生产形态是“网关 + request workflow worker + 共享状态/事件基础设施”,但这仍属于部署推测。
## 11. 可以确认与不能确认的内容
### 11.1 可以直接确认
- 客户端使用 Connect-ES 1.6.1。
- 业务消息使用 protobuf。
- BidiAppend 是 unary 上行,RunSSE 是 server-streaming 下行。
- RunSSE 使用 Connect 二进制 envelope,而非标准文本 SSE。
- 同一 conversation 的不同 turn 使用不同 `request_id`
- 上行消息通过 `append_seqno` 排序。
- 客户端参与 KV/blob 存取和 checkpoint 延续。
- 每个成功样本最终都收到 EndStream 帧。
### 11.2 由协议必然产生的架构约束
- 服务端必须将两个独立 HTTP 通道汇合到同一个活动请求状态。
- 服务端必须处理并发、乱序、重复或重试的 BidiAppend。
- 服务端需要维护等待中的工具、KV 和 Interaction 关联状态。
- RunSSE 断开时,服务端必须决定取消、保留或允许恢复活动 run。
### 11.3 当前不能确认
- 原服务使用的编程语言和服务框架。
- 客户端到服务端实际使用 HTTP/1.1 还是 HTTP/2。
- 是否使用 Redis、Kafka、Temporal、Orleans、Akka 或其他具体基础设施。
- 是否依赖负载均衡粘性会话。
- 服务端是否也持久保存完整 checkpoint blob。
- RunSSE 重连时 backlog 的保留期限和恢复游标协议。
## 12. 总结
原 Agent 系统可以概括为一个分布式状态机:云端持有推理控制和 workflow,客户端持有 IDE 执行能力并参与会话状态存取。Connect RPC 提供传输封装,BidiAppend 和 RunSSE 共同模拟逻辑双向流,`request_id` 绑定一次活动运行,`conversation_id` 绑定跨 turn 的持久会话,checkpoint 与内容寻址 blob 负责状态延续。
这种设计的主要目的不是单纯流式输出文本,而是在浏览器兼容的 HTTP RPC 上承载可恢复、可排序、可调用本地工具的远程 Agent runtime。
+338
View File
@@ -0,0 +1,338 @@
# KV 协议详细分析
本文专门分析 Agent 协议中的 `KvServerMessage` / `KvClientMessage`。分析依据包括当前 `agent_v1.proto`、Connect 帧结构和本地 SQLite 抓包。
本文中的 KV 不指普通业务配置表,而指服务端通过 RunSSE 调用客户端 Blob Store 的协议。
## 1. 一句话结论
KV 是一个**客户端参与的内容寻址 Blob RPC**:
- 服务端请求客户端按 `blob_id` 保存或读取二进制内容。
- `blob_id` 是 Blob 内容的稳定地址,而不是随机数据库主键。
- Blob 主要用于 conversation checkpoint、prompt context 和其他大型上下文。
- KV 操作发生在 Agent 流内部,不是独立的 HTTP KV 服务。
更准确的技术名称是:
> Client-side content-addressed Blob Store over an application-level reverse RPC.
## 2. 协议分层
KV 不是直接出现在 HTTP body 顶层,而是嵌套在两条 Connect RPC 中。
### 2.1 服务端到客户端
```text
Connect server stream
-> AgentServerMessage
-> KvServerMessage
-> GetBlobArgs / SetBlobArgs
```
对应的 protobuf
```protobuf
message KvServerMessage {
uint32 id = 1;
optional SpanContext span_context = 4;
oneof message {
GetBlobArgs get_blob_args = 2;
SetBlobArgs set_blob_args = 3;
}
}
```
### 2.2 客户端到服务端
```text
Connect unary BidiAppend
-> BidiAppendRequest
-> data: hex(AgentClientMessage)
-> KvClientMessage
-> GetBlobResult / SetBlobResult
```
对应的 protobuf
```protobuf
message KvClientMessage {
uint32 id = 1;
oneof message {
GetBlobResult get_blob_result = 2;
SetBlobResult set_blob_result = 3;
}
}
```
因此,KV 的“请求方向”是 RunSSE,下行;KV 的“响应方向”是 BidiAppend,上行。这是应用层反向 RPC,不是客户端直接向某个 `/kv` HTTP endpoint 发请求。
## 3. 消息和参数
### 3.1 `GetBlobArgs`
```protobuf
message GetBlobArgs {
bytes blob_id = 1;
}
```
功能:要求客户端返回指定 Blob。
`blob_id` 是二进制字段。当前抓包中长度为 32 字节,显示为 Base64 时通常是 44 个字符。
### 3.2 `GetBlobResult`
```protobuf
message GetBlobResult {
optional bytes blob_data = 1;
optional Error error = 2;
}
```
成功时返回 `blob_data`;读取失败时返回 `error.message`。协议没有单独定义 `not_found` 枚举,缺失、损坏和存储错误都需要通过 Error 文本表达。
### 3.3 `SetBlobArgs`
```protobuf
message SetBlobArgs {
bytes blob_id = 1;
bytes blob_data = 2;
}
```
功能:要求客户端按指定地址保存一段完整的 Blob。
KV 本身没有分片字段。一个 Blob 必须在一条 `SetBlobArgs` 中完整传输;大型内容依靠 Connect 的压缩和多个 Blob 拆分,而不是依靠 KV 内部的 chunk 序号。
### 3.4 `SetBlobResult`
```protobuf
message SetBlobResult {
optional Error error = 1;
}
```
没有 `error` 表示写入成功;有 `error` 表示客户端拒绝或无法保存。
### 3.5 `SpanContext`
`KvServerMessage.span_context` 可携带 `trace_id``span_id``trace_flags``trace_state`。它用于分布式追踪,不参与 Blob 寻址、版本控制或响应关联。
## 4. 三个 ID 的区别
KV 运行时同时存在三种容易混淆的 ID:
| ID | 所属 | 作用 | 生命周期 |
| --- | --- | --- | --- |
| `request_id` | Bidi / RunSSE | 绑定一条 Agent 活动流 | 一次 turn 或运行实例 |
| `KvServerMessage.id` | KV 操作 | 关联服务端操作和客户端结果 | 当前 `request_id` 内的一次操作 |
| `blob_id` | Blob 内容 | 内容寻址和引用 | 只要内容或 checkpoint 仍可达就有效 |
此外还有 `BidiAppendRequest.append_seqno`
- `KvServerMessage.id` 解决“哪个 KV 响应对应哪个 KV 请求”。
- `append_seqno` 解决“所有客户端上行消息应按什么顺序处理”。
- 两者不能互相替代。
本地样本中每个新的 `request_id` 都将 KV 操作 ID 从 0 重新开始,而 Bidi 上行序号还会被 heartbeat、Exec 和其他客户端消息占用。
## 5. 内容寻址规则
当前样本明确验证出:
```text
blob_id = SHA-256(blob_data)
```
验证结果:
| 检查项 | 结果 |
| --- | ---: |
| 三个 turn 中观察到的 `set_blob_args` | 30 |
| `blob_id == SHA-256(blob_data)` | 30 / 30 |
| 已观察的 `get_blob_result` | 2 |
| 读取结果通过请求 ID 的 SHA-256 校验 | 2 / 2 |
协议字段本身没有声明哈希算法或版本字段,因此 SHA-256 是根据实际数据推断出来的协议约定。实现时仍应把算法视为可配置或保留版本扩展空间,而不应只依赖“32 字节”这一表象。
内容寻址带来三个直接性质:
1. 相同内容得到相同 ID,可以去重。
2. 内容变化必然得到新 ID,Blob 可以视为不可变对象。
3. 客户端和服务端都能通过重新计算哈希校验传输是否损坏。
空内容也有对应的内容地址。样本中的空 rules 和 subagents 使用 SHA-256 空串值:
```text
e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
```
## 6. Blob 引用的两类主要用途
### 6.1 Conversation checkpoint
`ConversationStateStructure` 的多个 bytes 字段实际可以承载 Blob 引用,例如:
- `turns[]`
- `root_prompt_messages_json[]`
- `conversation_state_blob_id`
- `prompt_context_usage_snapshot_blob_id`
在当前样本中,Turn Blob 可以解码为 `ConversationTurnStructure`,其结构为:
```text
ConversationTurnStructure
└─ AgentConversationTurnStructure
├─ user_message: blob_id
├─ steps[]: blob_id
└─ request_id
```
UserMessage Blob 可以解码为 `UserMessage`,其中又包含 `conversation_state_blob_id`。这个状态 Blob 继续引用根 Prompt Blob 和其他 checkpoint 数据。
因此 checkpoint 不是一个扁平 JSON,而是一个由多个 protobuf Blob 组成的引用图。
### 6.2 Request context
`ConversationAction.request_context_parts` 使用专门的引用结构:
```protobuf
message RequestContextPartReferences {
bytes rules_blob_id = 1;
uint32 rules_byte_length = 2;
bytes skills_blob_id = 3;
uint32 skills_byte_length = 4;
bytes subagents_blob_id = 5;
uint32 subagents_byte_length = 6;
bytes mcps_blob_id = 7;
uint32 mcps_byte_length = 8;
RequestContext dynamic_context = 9;
}
```
这些 Blob 用来传输较大的 rules、skills、subagents 和 MCP 定义;小型、动态字段继续放在 `dynamic_context` 内。
样本中第二、第三个 turn 的 `get_blob_args` 分别读取:
| Turn | 引用类型 | Blob 大小 |
| --- | --- | ---: |
| 2 | `request_context_parts.mcps_blob_id` | 29,974 字节 |
| 3 | `request_context_parts.mcps_blob_id` | 59,145 字节 |
因此,KV 不只服务于 conversation history,也服务于每轮模型调用需要的大型上下文。
## 7. 当前会话的真实时序
### 7.1 第一个 turn
- 发送 9 个 `set_blob_args`
- 客户端返回 9 个 `set_blob_result`
- 其中包括 UserMessage、ConversationStep、ConversationTurn 和 Prompt/State 相关 Blob。
- 最终 checkpoint 的 `turns[]` 引用本轮的 Turn Blob。
### 7.2 第二个 turn
- `run_request` 携带上一轮 conversation state 和新的 request context 引用。
- 服务端读取 1 个 MCP context Blob,返回数据 29,974 字节。
- 服务端发送 7 个新 Blob,包括本轮消息、步骤、turn 和新的 context 状态。
- 服务端发布新的 checkpoint。
### 7.3 第三个 turn
- 服务端读取新的 MCP context Blob,返回数据 59,145 字节。
- 服务端发送 14 个新 Blob。
- 该 turn 还出现了 Exec 请求和结果,说明 KV 与本地工具协议可以在同一个 request actor 中并行存在。
一个重要结论是:当前样本中的 `get_blob` 不应简单解释为“服务端从客户端读取上一轮对话历史”。实际观察到的 `get_blob` 是 MCP request context。历史 Turn Blob 可能由服务端缓存,也可能在其他未捕获的路径同步;本样本不足以证明其读取路径。
## 8. 并发、顺序和幂等
### 8.1 多个 KV 请求可以并发
服务端可以在一条 RunSSE 中连续发送多个 `set_blob_args`。客户端随后并发发起多个 BidiAppend。
当前样本中,KV 操作 ID 和 HTTP 到达顺序不一致。例如一个 turn 中操作 ID 3、4、5 的响应在抓包记录里并非严格按 3、4、5 排列。这说明服务端不能按 HTTP 请求到达顺序匹配 KV 结果,必须按 `KvClientMessage.id` 关联。
### 8.2 `append_seqno` 是全局上行顺序
KV 结果的 BidiAppend 还会与 heartbeat、Exec 结果共享同一个 `append_seqno` 序列。因此:
- KV 操作 ID 只在 KV 子协议中使用。
- append 序号覆盖所有 `AgentClientMessage`
- 服务端需要先按 append 序号处理上行消息,再按 KV ID 将结果交给对应的等待状态。
### 8.3 写入幂等和 ACK
一次成功的 KV 写入有两层确认:
1. HTTP/Connect 层返回 `BidiAppendResponse`,表示上行 append 被接收。
2. `KvClientMessage.set_blob_result` 没有错误,表示客户端 Blob Store 确实完成写入。
只有第二层确认才代表 Blob 可被后续 checkpoint 引用。重复发送同一个 `blob_id` 不会改变内容,但服务端仍需要处理重复的操作 ID、过期结果和客户端重试。
## 9. 失败语义和边界
KV 协议没有独立的错误枚举、删除、列举、TTL 或批量操作。当前可表达的失败主要是:
- `get_blob_result.error`:客户端找不到或无法读取 Blob。
- `set_blob_result.error`:客户端无法保存 Blob。
- BidiAppend 本身失败:上行 append 未被服务端接受。
- RunSSE 断开或 EndStream 失败:下行 KV 请求可能尚未完成。
因此服务端需要维护 pending KV 操作表:
```text
(request_id, KvServerMessage.id)
-> blob_id
-> waiting checkpoint / turn completion
```
当必要 Blob 写入失败或超时,服务端不能发布引用该 Blob 的成功 checkpoint;应选择重试、降级为未完成状态或结束当前 turn。
## 10. 安全与存储含义
KV 内容通过 HTTPS/Connect 传输,但协议本身没有声明 Blob 的存储加密、租户命名空间或访问权限。生产实现至少应考虑:
- 按用户、workspace 或 conversation 做访问隔离,不能只依赖公开的 SHA-256 值。
-`blob_data` 做大小限制和哈希校验。
- 不把 Blob 正文写入普通请求日志。
- 对未知或过期 `KvClientMessage.id` 做幂等处理。
- 防止通过任意 `get_blob` 探测其他会话的内容。
- 明确客户端 Blob 的持久化、清理和迁移策略。
由于协议没有 delete 或 garbage-collection RPCBlob 生命周期很可能由客户端本地存储策略、checkpoint 可达性或服务端外部存储策略负责。具体实现无法从当前抓包确认。
## 11. 对重写服务的直接启示
KV 不应被建模成一个简单的 `map[string][]byte` API。更合适的抽象是:
```text
BlobStore
Put(content) -> content_hash
Get(content_hash) -> content
Has(content_hash) -> bool
```
上层再增加一次 request-scoped 的 RPC 编排:
```text
BlobOperation
operation_id
request_id
blob_id
kind: get | set
status: pending | succeeded | failed | timed_out
```
Checkpoint 只保存 Blob 引用和小型元数据;Blob 本体由可替换的客户端存储适配器或共享存储适配器负责。KV 操作完成后,必须通过明确的 barrier 通知 checkpoint/turn 状态机继续收口。
## 12. 最终结论
KV 是 Agent 协议中的状态同步层,承担三个角色:
1. **checkpoint 的内容存储**:把历史消息、步骤和 turn 拆成不可变 Blob。
2. **大型上下文传输**:通过引用传递 Rules、Skills、Subagents 和 MCP 数据。
3. **客户端能力桥接**:云端通过 RunSSE 请求本地客户端保存或读取 Blob,再通过 BidiAppend 获得结果。
所以它不是普通的 KV 缓存,而是连接云端 Agent workflow、客户端本地状态和可恢复 conversation 的关键协议层。
+889
View File
@@ -0,0 +1,889 @@
# 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 本身不需要增加状态分支。
@@ -0,0 +1,553 @@
# 前后端 ConnectRPC 重构完整方案
## 1. 目标
本方案重构桌面端、前端、本机控制面、代理层和具体服务端实现之间的边界。
最终目标如下:
1. 前端业务通信全部使用 ConnectRPC,不再使用任何 Wails 业务 IPC。
2. `internal/startup` 只负责依赖组装、启动顺序、运行时注册和优雅退出。
3. 操作系统与 Wails 能力统一收敛到 `internal/platform`
4. `internal/backend/app` 只负责产品级本机控制面。
5. Cursor 协议、Agent 和 Prompt 全部归 `internal/backend/cursor`
6. Runtime 是通用服务运行时,不绑定 Cursor,也不使用“Cursor backend”作为领域名称。
7. 当前 Cursor Host 与 MITM 只是一个 Runtime 实现,未来可以并列注册 Devin 等实现。
8. Cursor Host 未处理的接口返回 `404`;代理层未命中的请求原样转发到原始上游。
## 2. 强制边界
### 2.1 禁止业务 IPC
前端禁止继续使用以下能力:
```text
@bindings
Call.ByName
Events.On
Events.Emit
application.NewService
```
Wails 只负责桌面应用生命周期和 WebView,不再承载配置、运行时、模型或事件等业务接口。
### 2.2 Runtime 不绑定 Cursor
`backend/app/runtime.go` 表达的是通用运行时用例:
- 列出可用运行时;
- 启动、停止和重启指定运行时;
- 查询状态和最近一次错误;
- 向前端发布运行时状态变化。
它不能出现以下设计:
```text
CursorBackend
StartCursor
StopCursor
CursorMITMStatus
```
Cursor Host、MITM 和系统代理的组合只存在于启动装配阶段,不进入 App 的通用 DTO。
## 3. 总体架构
```mermaid
flowchart LR
UI["Frontend"] -->|"ConnectRPC"| APP["app.v1.AppService"]
APP --> APPDOMAIN["backend/app"]
APPDOMAIN -->|"RuntimeController"| SUPERVISOR["startup.Supervisor"]
SUPERVISOR --> CURSORRT["Cursor Runtime"]
SUPERVISOR --> DEVINRT["Devin Runtime"]
SUPERVISOR --> FUTURERT["Future Runtime"]
CURSORRT --> CURSORHOST["Cursor Host"]
CURSORRT --> MITM["MITM"]
CURSORRT --> SYSPROXY["platform/network"]
CURSORIDE["Cursor IDE"] --> MITM
MITM -->|"模型和 Agent 路由"| CURSORHOST
MITM -->|"其他请求原样转发"| UPSTREAM["原始上游"]
```
## 4. 目标目录
```text
internal/
├── startup/
│ ├── bootstrap.go
│ ├── wiring.go
│ └── supervisor.go
├── platform/
│ ├── desktop/
│ │ ├── app.go
│ │ ├── window.go
│ │ ├── tray.go
│ │ └── browser.go
│ ├── filesystem/
│ │ ├── paths.go
│ │ └── migrate.go
│ ├── network/
│ │ └── system_proxy.go
│ └── update/
│ └── installer.go
├── backend/
│ ├── app/
│ │ ├── host.go
│ │ ├── module.go
│ │ ├── service.go
│ │ ├── snapshot.go
│ │ ├── events.go
│ │ ├── config.go
│ │ ├── runtime.go
│ │ ├── model.go
│ │ ├── update.go
│ │ ├── desktop.go
│ │ ├── repository.go
│ │ ├── proto/
│ │ │ ├── app_v1.proto
│ │ │ └── types_v1.proto
│ │ └── gen/appv1/
│ │
│ ├── cursor/
│ │ ├── module.go
│ │ ├── host.go
│ │ ├── prompt/
│ │ ├── llm/
│ │ ├── loop/
│ │ ├── store/
│ │ ├── transport/
│ │ ├── proto/
│ │ │ ├── agent_v1.proto
│ │ │ ├── aiserver_v1.proto
│ │ │ ├── from_extensions/
│ │ │ ├── extractor/
│ │ │ └── scripts/
│ │ └── gen/
│ │ ├── agentv1/
│ │ └── aiserverv1/
│ │
│ └── devin/
│ └── .gitkeep
└── proxy/
├── server.go
├── router.go
├── passthrough.go
└── certificate.go
```
前端目标目录如下:
```text
frontend/src/rpc/
├── transport.js
├── appClient.js
├── watch.js
└── gen/
└── appv1/
```
`backend/app` 保持单一扁平 Go package,不按配置、模型等功能继续拆子目录。只有 protobuf 源文件和生成代码保留独立目录。
## 5. 模块职责
### 5.1 `internal/startup`
`startup` 是唯一组合根,负责:
- 创建数据库连接;
- 执行各模块声明的迁移;
- 创建 App、Cursor、Proxy 和 Platform 实例;
- 注入模块依赖;
- 注册所有 Runtime 实现;
- 确定启动和停止顺序;
- 捕获退出信号并等待资源释放。
`startup` 不负责窗口、托盘、浏览器、系统代理命令等具体平台操作,这些能力必须通过 `platform` 注入。
### 5.2 `internal/platform`
`platform` 只包装本机和操作系统能力:
- `desktop`Wails、窗口、托盘和浏览器;
- `filesystem`:数据目录、配置目录、日志目录和文件迁移;
- `network`:系统代理读取、设置和恢复;
- `update`:安装包验证与执行。
只有 `platform/desktop` 可以直接导入 Wails application API。`platform` 不依赖 protobuf、AppService 或 Cursor 协议。
### 5.3 `internal/backend/app`
App 是产品级本机控制面,负责:
- 产品配置;
- 通用 Runtime 控制;
- BYOK 模型配置和连通性测试;
- 应用更新状态;
- 受控桌面动作;
- App 快照和 App 事件流。
App 不负责:
- Cursor IDE 协议;
- MITM 实现;
- 具体 Runtime 的启停细节;
- 操作系统命令。
### 5.4 `internal/backend/cursor`
Cursor 模块拥有所有 Cursor 专属语义:
- Cursor Host 路由;
- Cursor Connect、Bidi 和 RunSSE 协议;
- Agent Loop、Prompt、LLM 和会话存储;
- Cursor 提取的 protobuf 和生成代码。
Cursor 不导入 `backend/app`。需要模型目录等通用数据时,由 Cursor 自己声明小接口,再由 `startup` 注入实现。
### 5.5 `internal/proxy`
Proxy 是通用代理基础设施,不导入 Cursor package。
Cursor 模块提供它能处理的路由集合,`startup` 将路由匹配器和目标地址注入 Proxy。未命中的请求必须保留原请求的 method、path、query、header、body 和流式响应语义,并发送到原始上游。
## 6. Runtime 设计
### 6.1 App 侧端口
`backend/app/runtime.go` 定义通用端口:
```go
// RuntimeController 管理已注册的服务运行时。
type RuntimeController interface {
List(context.Context) ([]RuntimeDescriptor, error)
Start(context.Context, string) error
Stop(context.Context, string) error
Restart(context.Context, string) error
Status(context.Context, string) (RuntimeStatus, error)
}
```
Runtime DTO 只包含通用字段:
```text
RuntimeDescriptor {
id
kind
state
capabilities
endpoint
last_error
revision
}
```
其中 `id` 标识一个配置实例,`kind` 标识实现类型,例如 `cursor``devin`。App 和前端不能根据 Cursor 专属字段决定运行时流程。
### 6.2 Supervisor
`startup/supervisor.go` 实现 `RuntimeController`,维护 Runtime 注册表和状态机:
```text
Stopped -> Starting -> Running -> Stopping -> Stopped
-> Failed
```
必须满足:
- 同一个 Runtime 的启停操作串行执行;
- 重复 Start 和 Stop 具有幂等语义;
- 启动中途失败时回滚已经启动的组件;
- Stop 按 Start 的逆序执行;
- 状态变化携带递增 revision;
- 应用退出时统一停止所有已启动 Runtime。
### 6.3 当前 Cursor Runtime
当前在 `startup/wiring.go` 注册一个 `kind=cursor` 的 Runtime。它的启动顺序为:
1. 校验 Cursor 和模型配置;
2. 启动 Cursor Host
3. 启动 MITM
4. 根据配置启用系统代理;
5. 发布 Running 状态。
停止时按相反顺序恢复系统代理、停止 MITM、停止 Cursor Host。
Cursor Host 和 MITM 是当前实现的内部组件,不应被命名为整个 Runtime。未来接入 Devin 时,只需注册新的 Runtime 实现,不修改 AppService 协议。
## 7. ConnectRPC 服务面
### 7.1 `app.v1.AppService`
第一阶段使用明确方法,不提供通用 JSON Invoke
```text
Bootstrap
Watch
GetConfig
UpdateConfig
ListRuntimes
GetRuntime
StartRuntime
StopRuntime
RestartRuntime
ListModels
SaveModel
DeleteModel
TestModel
GetAds
GetUpdate
CheckUpdate
InstallUpdate
OpenWindow
OpenExternal
```
App 的 `Watch` 第一条消息是完整 App 快照,后续发送带 revision 的增量事件:
```text
snapshot
config_changed
runtime_changed
model_changed
ads_changed
update_changed
```
### 7.2 Cursor IDE 协议服务
Cursor IDE 使用独立 Host。该 Host 只注册:
- 模型列表接口;
- Agent BidiAppend 接口;
- Agent RunSSE 接口。
其他路径全部返回 `404`,不能做代理兜底。代理兜底只能发生在 Proxy 层。
## 8. 两个本地 Host
### 8.1 App Host
App Host 绑定随机回环地址 `127.0.0.1:0`,负责:
- 提供前端静态资源;
- 注册 AppService
- 校验 Origin 和本地会话;
- 提供 ConnectRPC 流式响应。
桌面启动时生成一次性 bootstrap token。WebView 首次访问 bootstrap 地址后,Host 写入 `HttpOnly``SameSite=Strict` Cookie,并重定向到普通首页。前端代码不长期保存 token。
### 8.2 Cursor Host
Cursor Host 绑定 Cursor 配置要求的本机地址,只服务 Cursor IDE 协议。它与 App Host 使用不同的路由表和认证规则。
## 9. 前端架构
前端只保留 `appClient`,负责配置、Runtime、模型、更新和桌面动作。
启动流程如下:
1. 创建同源 Connect-Web transport
2. 调用 AppService `Bootstrap`
3. 启动 `Watch`
4. 按 revision 丢弃重复或乱序事件;
5. 流断开后退避重连,并重新取得完整快照。
前端不导入 Wails runtime,也不通过全局事件总线传递后端状态。
## 10. 数据库与依赖注入
启动过程固定为:
1. `platform/filesystem` 解析数据路径;
2. `startup` 打开数据库连接;
3. App 和 Cursor 分别提供自己的迁移集合;
4. `startup` 按版本执行迁移;
5. 创建 App Repository 和 Cursor Repository
6. 将接口注入对应 Service
7. 注册 ConnectRPC Handler
8. 启动 App Host 和桌面窗口。
模块只能访问自己拥有的表。跨模块调用使用接口,不共享数据库 DTO。
## 11. Proto 和生成代码
新建的产品控制协议位于:
```text
internal/backend/app/proto
internal/backend/app/gen
```
所有 Cursor 专属协议位于:
```text
internal/backend/cursor/proto
internal/backend/cursor/gen
```
当前根目录的 `proto``gen` 以及协议提取器都要迁入 Cursor 模块。提取器必须按 parser、symbols、renderer 等职责拆分,单文件禁止超过 500 行。
前端只生成 AppService 所需的 Web 客户端,不把 Cursor IDE 上游协议暴露给 UI。
## 12. 依赖方向
允许的依赖方向如下:
```text
main -> startup
startup -> platform
startup -> backend/app
startup -> backend/cursor
startup -> proxy
frontend -> app.v1
backend/app -> 自己声明的端口
backend/cursor -> 自己声明的端口
proxy -> 注入的路由和目标接口
```
禁止以下依赖:
```text
backend/app -> backend/cursor
backend/cursor -> backend/app
platform -> backend
platform -> protobuf
proxy -> backend/cursor
任何业务包 -> startup
```
## 13. 现有代码迁移映射
```text
internal/app/runner.go
-> startup/bootstrap.go
-> startup/wiring.go
-> platform/desktop/*
internal/bridge/*
-> 删除
internal/client 中的产品配置、模型、更新
-> backend/app 对应文件
internal/appdata
-> platform/filesystem
系统代理操作
-> platform/network
更新状态与检查
-> backend/app/update.go
安装命令
-> platform/update/installer.go
根 proto、gen 和提取器
-> backend/cursor/proto
-> backend/cursor/gen
```
## 14. TDD 实施顺序
### 阶段一:建立架构守卫
先写失败测试,检查:
- 前端禁止的 Wails IPC 标识;
- App 与 Cursor 禁止互相导入;
- Wails application API 只能出现在 `platform/desktop`
- 根目录不再存在 Cursor `proto``gen`
- 所有手写源码不超过 500 行。
### 阶段二:建立 App ConnectRPC Host
先测试再实现:
- loopback 随机端口;
- bootstrap token 换取 Cookie
- AppService unary 调用;
- Watch 首包快照和 revision
- 非法 Origin 和无会话请求拒绝。
### 阶段三:实现通用 Runtime
使用两个 Fake Runtime 先验证:
- 注册和列出多个 kind
- 幂等 Start 和 Stop
- 并发操作串行化;
- 部分启动失败回滚;
- 逆序停止;
- 状态 revision
- Cursor Runtime 和 Devin Runtime 不需要修改 AppService。
然后再把 Cursor Host、MITM 和系统代理接入 Cursor Runtime。
### 阶段四:切换前端
先为 RPC 状态层编写测试,再替换现有 bindings 和 Events。切换完成后删除 `internal/bridge` 与所有 Wails 业务服务注册。
### 阶段五:迁移 Cursor Proto
迁移 Cursor IDE 协议源文件、生成代码和提取器,并使用协议 fixture 验证迁移前后字节结果一致。
### 阶段六:清理和集成验证
删除旧接口、旧事件、旧生成代码和空目录,运行完整单元测试、集成测试、静态检查与编码风格检查。
## 15. 必须覆盖的测试
### App Host
- 首次 bootstrap 成功且 token 只能使用一次;
- Connect unary 和 server stream 可用;
- 重连后重新获得完整快照;
- Host 停止后连接和 goroutine 全部退出。
### Runtime
- 多种 Runtime 并存;
- 状态转换合法;
- Cursor 启动顺序正确;
- Cursor 停止顺序与启动相反;
- MITM 启动失败时 Cursor Host 被回滚;
- 应用退出时所有 Runtime 被停止。
### Cursor Host 与 Proxy
- 模型列表和 Agent 接口可访问;
- Cursor Host 的其他路径返回 `404`
- Proxy 只拦截 Cursor Host 明确支持的路由;
- 其他请求的 method、path、query、header、body、status 和响应流保持透传语义。
### 前端
- 不存在 Wails bindings 和业务 Events
- App 状态订阅只通过 AppService
- 重复 revision 不会重复更新状态;
- 断流后可以恢复快照和订阅。
## 16. 完成标准
满足以下条件才算重构完成:
1. 前端业务链路全部经过 ConnectRPC。
2. `internal/bridge` 已删除。
3. `backend/app` 只包含产品级配置、Runtime 和桌面控制能力。
4. Runtime API、DTO、状态和测试均不绑定 Cursor。
5. Cursor Host 与 MITM 只作为已注册 Runtime 的当前实现。
6. Cursor 专属 proto、gen 和提取器全部位于 `backend/cursor`
7. Cursor Host 未注册路径稳定返回 `404`
8. Proxy 未命中请求稳定透传到原始上游。
9. 只有 `platform/desktop` 直接使用 Wails application API。
10. 所有新增和调整的源码、测试均使用简洁中文注释,单文件不超过 500 行。
+140
View File
@@ -0,0 +1,140 @@
以下只基于当前代码。
**1. 当前请求 `AgentClientMessage.oneof message` 类型**
协议定义了 8 类上行消息,[agent_v1.proto](/Users/leokun/Documents/cursor-byok/internal/backend/cursor/proto/agent_v1.proto:57)
1. `run_request`
- 新建/恢复一次 Agent 执行。
- 当前提取 `conversation_id`、conversation state、action、用户消息、request context、模型、thinking effort、mode、subagent 信息。
2. `prewarm_request`
- 建立运行态和 checkpoint,但不启动 provider。
3. `conversation_action`
- 会启动 Run`user_message``resume``summarize``start_plan``execute_plan`
- 会取消:`cancel`
- 其他 action 当前基本按 metadata 处理。
4. `exec_client_message`
- 客户端工具执行数据或结果。
- 当前主要处理 Read、Write、Delete、Glob/Grep、Diagnostics、Ls、ShellStream、MCP、Subagent、WriteShellStdin、ForceBackgroundShell、ExecuteHook。
5. `exec_client_control_message`
- `stream_close``throw``heartbeat`
6. `interaction_response`
- 当前处理 AskQuestion、CreatePlan、WebSearch、WebFetch、SwitchMode 的客户端响应。
7. `kv_client_message`
- proto 支持 `get_blob_result``set_blob_result`;当前业务主要消费 `set_blob_result`,用于 checkpoint blob 确认。
8. `client_heartbeat`
- 当前归为 metadata,不推进执行状态。
识别和内部 intent 映射集中在 [inbound.go](/Users/leokun/Documents/cursor-byok/internal/backend/agent/protocol/inbound.go:60) 与 [service.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/service.go:543)。
---
**2. 当前返回 `AgentServerMessage.oneof message` 类型**
外层 6 类全部有实际使用,[agent_v1.proto](/Users/leokun/Documents/cursor-byok/internal/backend/cursor/proto/agent_v1.proto:129)
1. `interaction_update`
- `text_delta`
- `thinking_delta`
- `thinking_completed`
- `summary_started`
- `summary`
- `summary_completed`
- `tool_call_started`
- `partial_tool_call`
- `tool_call_delta`
- `tool_call_completed`
- `shell_output_delta`
- `heartbeat`
- `turn_ended`
2. `exec_server_message`
- 服务端要求客户端执行工具。
- 当前包括 Read、Write、Delete、Grep、Ls、Diagnostics、ShellStream、WriteShellStdin、ForceBackgroundShell、MCP、MCP resource、Subagent、ExecuteHook。
3. `exec_server_control_message`
- 当前只有 `abort`,取消尚未完成的客户端执行。
4. `conversation_checkpoint_update`
- 返回完整的 `ConversationStateStructure` 投影。
5. `kv_server_message`
- 当前主要发送 `set_blob_args`,要求客户端保存 checkpoint blob。
6. `interaction_query`
- 当前包括 AskQuestion、CreatePlan、WebSearch、WebFetch、SwitchMode。
构造入口分别在 [events.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/events.go:17)、[exec bridge](/Users/leokun/Documents/cursor-byok/internal/backend/agent/bridge/exec/bridge.go:66) 和 [interaction bridge](/Users/leokun/Documents/cursor-byok/internal/backend/agent/bridge/interaction/bridge.go:66)。
另外,成功、取消、provider 错误不一定表现为 `oneMessage`:最终通过 `StreamEvent.End` 转换成 Connect end-stream 或结构化错误。
---
**3. 当前需要处理的协议信息**
传输层:
- `POST /aiserver.v1.BidiService/BidiAppend`Connect unary。
- `POST /agent.v1.AgentService/RunSSE`Connect server stream。
- RunSSE 响应头被强制兼容成 `text/event-stream`
- 实际消息仍由 Connect handler 负责 framing。
Bidi 外层:
- `request_id`:整条活动流的主键。
- `append_seqno`:同一 request 上行消息排序和去重。
- `data`:十六进制字符串,解码后才是 `AgentClientMessage protobuf`
- `data_binary`:proto 中存在,但当前实现没有使用。
- `BidiAppendResponse`:始终是空 ACK。
业务关联标识:
- `conversation_id`:持久化会话与历史。
- `request_id`:一次活跃请求以及 Bidi/RunSSE 配对。
- `turn_seq`:会话中的轮次。
- `model_call_id`:一次 provider pass。
- `tool_call_id`:模型工具调用。
- `ExecServerMessage.id + exec_id`:客户端执行请求和回包关联。
- `InteractionQuery.id`:交互查询和响应关联。
- `KvServerMessage.id`checkpoint blob 请求与确认关联。
还需要解析 conversation state、action、mode、requested model、thinking effort、request context、workspace/MCP/skill 信息。当前归一化后的协议载体是 [InboundIntent](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/types.go:418)。
---
**4. 当前怎样维护 Bidi 和 RunSSE 状态**
Bidi 顺序状态:
- `appendSequenceTracker``request_id` 建立状态。
- 维护 `next``processing``ready`
- 小于 `next` 的消息视为重复并忽略。
- 大于 `next` 的消息等待前序完成。
- Cursor 复用 `request_id` 且重新从 `append_seqno=1` 开始时,会在空闲状态重置序列。
- 状态空闲十分钟后清理。
- `append_seqno <= 0` 会绕过这个顺序机制。
见 [append_seq.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/append_seq.go:11)。
运行状态:
- `StreamBroker` 使用 `map[requestID]*ActiveStream`
- 每个 `ActiveStream` 保存 provider、phase、backlog、subscriber、pending exec、pending interaction、checkpoint 和工具运行状态。
- Bidi、provider event、timer 和 compaction event 都投递到该 stream 的单一 actor mailbox 串行处理。
- Phase 包括 `idle``provider_running``waiting_external``awaiting_user``compacting``checkpointing``completed/failed/canceled`
见 [types.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/types.go:126) 和 [actor.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/actor.go:18)。
RunSSE 状态:
- RunSSE 可以先于 Bidi 到达,此时 Broker 创建只有 `request_id` 的占位 stream。
- 每个 RunSSE 连接注册独立 subscriber,但数据事实源是共享 `Backlog []StreamEvent`
- `Publish` 先追加 backlog,再用容量为 1 的 signal 唤醒订阅者;signal 可以合并,但事件不会丢,因为客户端重新读取 backlog。
- 每个连接从本地 `cursor=0` 开始,所以重新连接会从头回放当前内存 backlog。
- backlog 暂时为空时,每 5 秒直接发送 heartbeatheartbeat 不进入 backlog。
- 最后一个订阅者断开后,给活跃请求 30 秒重连宽限期,之后 actor 执行取消。
- 终态 stream 在无订阅者时保留 30 秒,然后从 Broker 删除。
见 [broker.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/broker.go:131) 和 [service.go](/Users/leokun/Documents/cursor-byok/internal/backend/forwarder/service.go:419)。
关键结论:**Bidi/RunSSE 的活动状态、backlog、cursor、pending exec/interaction 都是内存态;持久化的是 conversation history/checkpoint,不是活动流本身。进程重启后无法恢复原 RunSSE backlog 和正在等待的桥接请求。**
+654
View File
@@ -0,0 +1,654 @@
# Agent Bidi / RunSSE 协议消息参考
本文描述 Agent Bidi / RunSSE 链路中的消息功能、字段语义和消息之间的关联关系。
本文是协议参考,不描述服务端或客户端的内部实现。字段定义以当前 `internal/backend/cursor/proto/agent_v1.proto``internal/backend/cursor/proto/aiserver_v1.proto` 为准。
范围包括 BidiAppend / RunSSE 传输封装、`AgentClientMessage` 的全部顶层分支、`AgentServerMessage` 的全部顶层分支,以及这些分支直接关联的主要请求、响应和控制消息。工具专属的 Args / Result 类型按功能归类,不展开为具体执行流程。
## 1. 协议概览
该协议将一次 Agent 通信拆成两条方向相反的通道:
- `BidiAppend`:客户端向服务端追加消息。
- `RunSSE`:服务端持续向客户端返回消息。
两条通道通过同一个 `request_id` 关联。
```text
客户端 服务端
| |
| BidiAppend(request_id, append_seqno, data)|
|------------------------------------------>|
| |
| RunSSE(request_id) |
|------------------------------------------>|
| |
| stream AgentServerMessage |
|<------------------------------------------|
```
### 1.1 主要关联标识
| 标识 | 范围 | 功能 |
| --- | --- | --- |
| `conversation_id` | 会话 | 标识一个可持续多轮的 Agent 会话。 |
| `request_id` | 请求流 | 关联 BidiAppend、RunSSE 和一次活跃请求。 |
| `run_id` | 运行 | 独立标识一次 Agent Run;不得假定它与 `request_id` 等值。 |
| `message_id` | 用户消息 | 标识一条用户输入。 |
| `model_call_id` | 模型调用 | 标识一次具体的模型调用或 provider pass。 |
| `call_id` / `tool_call_id` | 工具调用 | 标识模型发起的一次工具调用。 |
| `id` | 桥接消息 | 关联 Exec、Interaction 或 KV 的请求和响应。 |
| `exec_id` | 客户端执行 | 标识一次客户端执行任务,可跨多个流式消息。 |
| `append_seqno` | Bidi 请求流 | 表示同一 `request_id` 下客户端上行消息的顺序。 |
### 1.2 Connect 流式帧封装
RunSSE 中的每条消息都位于 Connect 流式帧中。帧由固定 5 字节帧头和消息载荷组成:
| 部分 | 长度 | 功能 |
| --- | --- | --- |
| `flags` | 1 字节 | 描述压缩和流结束状态。 |
| `length` | 4 字节 | 使用大端序表示后续载荷的字节数,不包含 5 字节帧头。 |
| `payload` | `length` 字节 | 普通帧中是 protobuf 消息;流结束帧中是结束状态。 |
`flags` 属于 Connect 传输层,不是 `AgentServerMessage` 或其他 protobuf 消息的字段。当前使用的标志位为:
| 标志 | 含义 |
| --- | --- |
| `0x00` | 普通、未压缩的数据帧。 |
| `0x01` | 压缩的数据帧,载荷需要按照流声明的压缩算法解压后再解析。 |
| `0x02` | 流结束帧,载荷表示 EndStream 状态,不应按业务 protobuf 消息解析。 |
这些值按 bit 表达:最低位 `0x01` 表示压缩,次低位 `0x02` 表示流结束,其余 bit 为保留位。因此,判断帧类型时应读取标志位,而不是把 `flags` 当作 protobuf 枚举。
例如下面的 RunSSE 帧:
```json
{
"kind": "interaction_update",
"messageType": "agent.v1.AgentServerMessage",
"flags": "0x00",
"length": 4,
"compressed": false,
"endStream": false,
"message": {
"interaction_update": {
"heartbeat": {}
}
}
}
```
它表示载荷是一个长度为 4 字节、未压缩且尚未结束流的 `AgentServerMessage``heartbeat` 消息很小,使用 `0x00` 是正常情况;较大的业务消息可能使用 `0x01`
## 2. BidiAppend 传输消息
### 2.1 `BidiAppendRequest`
功能:向指定请求流追加一条客户端消息。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `data` | `string` | 十六进制编码的 `AgentClientMessage` protobuf 数据。 |
| `request_id` | `BidiRequestId` | 指定消息所属的请求流。 |
| `append_seqno` | `int64` | 指定消息在当前请求流中的追加顺序。 |
| `data_binary` | `bytes` | 二进制形式的消息载荷。 |
约束:
- `data``data_binary` 表达的是消息载荷,不应同时承载语义不同的消息。
- `append_seqno` 只在同一个 `request_id` 内比较。
- 解码后的根消息必须是 `AgentClientMessage`
### 2.2 `BidiAppendResponse`
功能:确认本次 append 请求已经被接收。
该消息没有业务字段。它只确认 unary 请求本身,不代表 Agent Run 已经完成。
### 2.3 `BidiRequestId`
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `request_id` | `string` | 标识 BidiAppend 与 RunSSE 共享的请求流。 |
## 3. 客户端上行根消息
### 3.1 `AgentClientMessage`
功能:封装一条客户端到服务端的 Agent 消息。
`message``oneof`,一条消息只能选择以下一个分支:
| 分支 | 消息类型 | 功能 |
| --- | --- | --- |
| `run_request` | `AgentRunRequest` | 启动或恢复一次 Agent Run。 |
| `exec_client_message` | `ExecClientMessage` | 返回客户端工具执行的数据或结果。 |
| `kv_client_message` | `KvClientMessage` | 返回 blob 读取或写入结果。 |
| `conversation_action` | `ConversationAction` | 追加会话动作,例如继续、取消或执行计划。 |
| `exec_client_control_message` | `ExecClientControlMessage` | 返回客户端执行通道的控制事件。 |
| `interaction_response` | `InteractionResponse` | 回答服务端发起的用户交互请求。 |
| `client_heartbeat` | `ClientHeartbeat` | 表示客户端连接仍然活跃。 |
| `prewarm_request` | `PrewarmRequest` | 提前准备会话、模型和上下文。 |
## 4. `AgentRunRequest`
功能:携带启动或恢复 Agent Run 所需的会话状态、动作、模型与能力信息。
### 4.1 核心字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `conversation_state` | `ConversationStateStructure` | 客户端掌握的会话 checkpoint。 |
| `action` | `ConversationAction` | 本次 Run 要执行的会话动作。 |
| `model_details` | `ModelDetails` | 旧式或展示用途的模型信息。 |
| `requested_model` | `RequestedModel` | 本次实际请求的模型、参数和凭据。 |
| `conversation_id` | `string?` | 本次 Run 所属会话。 |
| `run_id` | `string?` | 客户端分配的 Run 标识。 |
### 4.2 工具和上下文字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `mcp_tools` | `McpTools` | 本次 Run 可用的 MCP 工具定义。 |
| `mcp_file_system_options` | `McpFileSystemOptions?` | MCP 文件系统能力和描述符。 |
| `skill_options` | `SkillOptions?` | 可用技能及技能加载选项。 |
| `custom_system_prompt` | `string?` | 调用方提供的自定义系统提示。 |
| `exclude_workspace_context` | `bool?` | 是否排除工作区上下文。 |
| `pre_fetched_blobs` | `PreFetchedBlob[]` | 调用前已经取得的 blob 内容。 |
### 4.3 模式和子 Agent 字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `subagent_type_name` | `string?` | 当前 Run 使用的子 Agent 类型。 |
| `selected_subagent_models` | `RequestedModel[]` | 为子 Agent 选择的模型。 |
| `selected_subagent_model_details` | `ModelDetails[]` | 子 Agent 模型的展示信息。 |
| `subagent_model_overrides` | `SubagentModelOverride[]` | 按子 Agent 类型覆盖模型选择。 |
| `can_create_cloud_subagents` | `bool?` | 客户端是否允许创建云端子 Agent。 |
| `suppress_subagent_progress_update_tool` | `bool?` | 是否隐藏子 Agent 进度更新工具。 |
| `conversation_group_id` | `string?` | 将多个相关会话归入同一组。 |
### 4.4 客户端能力字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `suggest_next_prompt` | `bool?` | 是否请求生成下一条提示建议。 |
| `harness` | `string?` | 标识调用方所使用的 Agent harness。 |
| `dev_raw_model_slug` | `string?` | 开发模式下使用的原始模型标识。 |
| `client_supports_inline_images` | `bool?` | 客户端是否支持内联图片。 |
| `client_supports_send_to_user` | `bool?` | 客户端是否支持 send-to-user 能力。 |
| `computer_use_coordinate_mode` | `string?` | Computer Use 坐标系模式。 |
## 5. `PrewarmRequest`
功能:提前提供模型、会话状态和能力声明,使后续正式 Run 可以复用已经准备好的上下文。
其主要字段与 `AgentRunRequest` 相同,但没有直接携带 `ConversationAction`
| 字段组 | 字段 |
| --- | --- |
| 模型 | `model_details``requested_model` |
| 会话 | `conversation_id``conversation_state``conversation_group_id` |
| 工具 | `mcp_tools``mcp_file_system_options` |
| Prompt | `custom_system_prompt``exclude_workspace_context` |
| 子 Agent | `subagent_type_name``selected_subagent_models``selected_subagent_model_details``subagent_model_overrides` |
| 客户端能力 | `suggest_next_prompt``client_supports_inline_images``client_supports_send_to_user``computer_use_coordinate_mode` |
| 预取 | `pre_fetched_blobs` |
| 候选选择 | `best_of_n_group_id``try_use_best_of_n_promotion` |
## 6. 模型选择消息
### 6.1 `RequestedModel`
功能:描述调用方实际希望使用的模型和运行参数。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `model_id` | `string` | Provider 模型标识。 |
| `max_mode` | `bool` | 是否启用该模型的 max 模式。 |
| `parameters` | `ModelParameterValue[]` | 额外模型参数,每项包含字符串 `id``value`。 |
| `built_in_model` | `bool` | 是否为内建模型。 |
| `is_variant_string_representation` | `bool` | `model_id` 是否表示模型变体字符串。 |
| `credentials` | `oneof` | `api_key_credentials``azure_credentials``bedrock_credentials`。 |
### 6.2 `ModelDetails`
功能:提供模型展示信息、别名、思考能力和凭据。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `model_id` | `string` | 模型标识。 |
| `display_model_id` | `string` | 面向 UI 的模型标识。 |
| `display_name` | `string` | 完整展示名称。 |
| `display_name_short` | `string` | 短展示名称。 |
| `aliases` | `string[]` | 可识别的模型别名。 |
| `thinking_details` | `ThinkingDetails?` | 模型思考能力声明。 |
| `max_mode` | `bool?` | 是否启用 max 模式。 |
| `credentials` | `oneof` | API Key、Azure 或 Bedrock 凭据。 |
凭据字段属于敏感信息,不应写入普通日志、错误消息或会话记录。
## 7. `ConversationAction`
功能:描述一次会话级动作。`action``oneof`
### 7.1 公共字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `triggering_auth_id` | `string?` | 触发动作的认证主体。 |
| `triggering_user_info` | `TriggeringUserInfo?` | 触发用户的信息。 |
| `request_context_parts` | `RequestContextPartReferences?` | 通过 blob 引用传递的大型上下文部分。 |
### 7.2 动作分支
| 分支 | 主要参数 | 功能 |
| --- | --- | --- |
| `user_message_action` | `user_message``request_context``prepend_user_messages``conversation_history` | 提交新用户消息并开始或继续会话。 |
| `resume_action` | `request_context` | 从已有 checkpoint 或等待点继续会话。 |
| `cancel_action` | `reason``interrupted_pending_tool_call_resolutions` | 取消当前 Run,并可携带未完成工具的解决结果。 |
| `summarize_action` | 无字段 | 请求生成或刷新会话摘要。 |
| `shell_command_action` | `shell_command``exec_id` | 将一次显式 Shell 命令写入会话。 |
| `start_plan_action` | `user_message``request_context``is_spec` | 进入计划编制流程。 |
| `execute_plan_action` | `request_context``plan`、计划文件字段、`execution_mode``plan_id` | 执行已有计划。 |
| `async_ask_question_completion_action` | `original_tool_call_id``original_args``result` | 回填异步 AskQuestion 的结果。 |
| `cancel_subagent_action` | `subagent_id` | 取消指定子 Agent。 |
| `background_task_completion_action` | `completions[]` | 上报后台 Shell 或子 Agent 的进度和终态。 |
| `background_shell_action` | `tool_call_id` | 将指定 Shell 工具调用切换到后台语义。 |
| `background_subagent_action` | `tool_call_id` | 将指定子 Agent 工具调用切换到后台语义。 |
| `subscription_notification_action` | `notifications[]``request_context` | 将订阅系统产生的消息注入会话。 |
| `goal_continuation_action` | 无字段 | 继续当前长期目标。 |
| `inject_context_action` | `injection_id``expected_run_id``user_context/system_context` | 向仍在运行的 Run 注入上下文。 |
## 8. `UserMessage`
功能:描述用户输入及其选择的上下文和运行模式。
| 字段组 | 字段 | 功能 |
| --- | --- | --- |
| 内容 | `text``rich_text``text_blob_id``rich_text_blob_id` | 用户输入的纯文本、富文本或 blob 引用。 |
| 身份 | `message_id``thread_id``prompt_reference_id` | 消息、线程和提示引用标识。 |
| 上下文 | `selected_context``conversation_state_blob_id` | 用户选择的文件、代码或会话状态。 |
| 模式 | `mode``custom_mode_intent` | Agent、Ask、Plan、Debug、Multitask 或自定义模式。 |
| 计划 | `execute_plan_info` | 当前消息关联的计划。 |
| 子 Agent | `subagent_system_reminder``project_details` | 子 Agent 或项目相关信息。 |
| 模拟消息 | `is_simulated_msg``simulated_msg_reason``simulated_message_metadata` | 标记系统代用户生成的输入。 |
| Hook | `hook_additional_contexts` | Hook 产生的附加上下文。 |
## 9. `RequestContext`
功能:描述本次请求可见的工作区、规则、工具和运行环境。
字段较多,按语义分组如下:
| 字段组 | 代表字段 | 功能 |
| --- | --- | --- |
| 环境 | `env` | OS、Shell、工作区路径、时区、终端目录、sandbox 与 Computer Use 能力。 |
| 规则 | `rules``non_file_rules``cloud_rule``disabled_team_rules` | 本次请求适用的规则集合。 |
| 仓库 | `repository_info``git_repos``project_layouts`、完整性标记 | 仓库索引、Git 和项目布局。 |
| MCP | `tools``mcp_instructions``mcp_file_system_options``mcp_meta_tool_options` | MCP 工具与文件系统能力。 |
| 技能 | `skill_options``agent_skills` | 可用技能和技能内容。 |
| 子 Agent | `custom_subagents` | 自定义子 Agent 声明。 |
| 文件 | `file_contents` | 已预取的路径到文件内容映射。 |
| Web | `web_search_enabled``web_fetch_enabled` | Web Search 和 Web Fetch 能力开关。 |
| Hook | `hooks_additional_context``hooks_config` | Hook 配置和附加上下文。 |
| 权限 | `user_permissions_auto_run``project_permissions_auto_run``admin_permissions_auto_run``admin_command_denylist` | 自动执行许可和禁止命令。 |
| 功能能力 | `supports_mcp_auth``read_lints_enabled``search_conversations_enabled``send_message_enabled` | 客户端可提供的附加能力。 |
大型上下文也可以通过 `RequestContextPartReferences` 传递。该结构为 rules、skills、subagents 和 MCP 分别携带 `blob_id` 与字节长度,并用 `dynamic_context` 继续携带小型动态字段。
## 10. `ExecClientMessage`
功能:返回 `ExecServerMessage` 所请求的客户端执行数据或结果。
### 10.1 公共字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `id` | `uint32` | 与对应 `ExecServerMessage.id` 相同。 |
| `exec_id` | `string` | 与对应 `ExecServerMessage.exec_id` 相同。 |
| `local_execution_time_ms` | `int32?` | 客户端本地执行耗时。 |
| `hook_additional_contexts` | `HookAdditionalContext[]` | 执行 Hook 返回的附加上下文。 |
| `message` | `oneof` | 工具特定的结果或流式事件。 |
### 10.2 主要结果分支
| 分类 | 分支 | 功能 |
| --- | --- | --- |
| 文件 | `read_result``redacted_read_result` | 返回文件读取结果。 |
| 文件 | `write_result``delete_result` | 返回文件写入或删除结果。 |
| 搜索 | `grep_result``ls_result` | 返回文本搜索、Glob 或目录列表结果。 |
| 诊断 | `diagnostics_result``canvas_diagnostics_result` | 返回代码或 Canvas 诊断结果。 |
| Shell | `shell_result``shell_stream` | 返回一次性 Shell 结果或流式 Shell 事件。 |
| Shell | `background_shell_spawn_result``write_shell_stdin_result``force_background_shell_result` | 返回后台 Shell 创建、输入和后台切换结果。 |
| 上下文 | `request_context_result` | 返回动态构建的 RequestContext。 |
| MCP | `mcp_result``list_mcp_resources_exec_result``read_mcp_resource_exec_result``mcp_state_exec_result` | 返回 MCP 调用和资源操作结果。 |
| Hook | `execute_hook_result` | 返回 Hook 执行结果。 |
| 子 Agent | `subagent_result``force_background_subagent_result``subagent_await_result` | 返回子 Agent 运行、后台切换和等待结果。 |
| Web/Computer | `fetch_result``record_screen_result``computer_use_result` | 返回网页、录屏或 Computer Use 结果。 |
| 权限预检 | `shell_allowlist_precheck_result``mcp_allowlist_precheck_result``web_fetch_allowlist_precheck_result` | 返回 allowlist 检查结果。 |
| Git | `git_diff_response` | 返回 Git diff。 |
| Pi 工具 | `pi_read_result``pi_bash_result``pi_edit_result``pi_write_result``pi_grep_result``pi_find_result``pi_ls_result` | 返回 Pi 工具族的执行结果。 |
| 其他 | `smart_mode_classifier_result``conversation_search_result``agent_store_conflict_result` | 返回模式分类、会话搜索或 Agent Store 冲突处理结果。 |
对于 `shell_stream`,其内部 `event` 也是 `oneof`,常见事件包括:
- `start`:进程已经启动。
- `stdout`:标准输出增量。
- `stderr`:标准错误增量。
- `exit`:进程已经退出。
- `rejected`:执行请求被拒绝。
- `permission_denied`:缺少执行权限。
- `backgrounded`:进程已经转入后台。
## 11. `ExecClientControlMessage`
功能:描述客户端执行通道本身的状态,不承载正常工具结果。
`message``oneof`
| 分支 | 参数 | 功能 |
| --- | --- | --- |
| `stream_close` | `id` | 表示指定 Exec 数据流已经关闭。 |
| `throw` | `id``error``stack_trace?``error_code?` | 表示客户端执行通道异常终止。 |
| `heartbeat` | `id` | 表示指定 Exec 仍然存活。 |
## 12. `InteractionResponse`
功能:返回客户端或用户对 `InteractionQuery` 的响应。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `id` | `uint32` | 与对应 `InteractionQuery.id` 相同。 |
| `result` | `oneof` | 交互类型对应的响应。 |
当前主要响应分支:
| 分支 | 主要内容 | 功能 |
| --- | --- | --- |
| `ask_question_interaction_response` | `AskQuestionResult` | 返回问题答案、拒绝、错误或异步状态。 |
| `create_plan_request_response` | `CreatePlanResult` | 返回计划 URI以及成功或错误。 |
| `web_search_request_response` | `approved/rejected` | 批准或拒绝 Web Search。 |
| `web_fetch_request_response` | `approved/rejected` | 批准或拒绝 Web Fetch。 |
| `switch_mode_request_response` | `approved/rejected` | 批准或拒绝模式切换。 |
协议还定义 VM 环境、PR 管理、MCP Auth、图片生成、环境替换和 SCM 连接等响应分支。
## 13. `KvClientMessage`
功能:返回 `KvServerMessage` 发起的 blob 操作结果。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `id` | `uint32` | 与对应 `KvServerMessage.id` 相同。 |
| `get_blob_result` | `GetBlobResult` | 返回 `blob_data``error`。 |
| `set_blob_result` | `SetBlobResult` | 返回可选的写入错误;无错误表示写入成功。 |
## 14. `ClientHeartbeat`
功能:表示客户端 Agent 通道仍然存活。
该消息没有业务字段,也不与 `ExecClientHeartbeat` 混用:
- `ClientHeartbeat` 面向整个 Agent 请求通道。
- `ExecClientHeartbeat` 面向某个具体 `ExecServerMessage.id`
## 15. RunSSE 请求与服务端根消息
### 15.1 RunSSE 请求
RunSSE 请求体是 `BidiRequestId`,只包含:
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `request_id` | `string` | 订阅指定请求流的服务端消息。 |
### 15.2 `AgentServerMessage`
功能:封装一条服务端到客户端的 Agent 消息。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `ttft_breakdown` | `TtftBreakdown` | 可选的首 token 延迟分解。 |
| `message` | `oneof` | 本条消息的业务载荷。 |
`message` 可以选择以下一个分支:
| 分支 | 消息类型 | 功能 |
| --- | --- | --- |
| `interaction_update` | `InteractionUpdate` | 返回文本、思考、工具和 turn 生命周期更新。 |
| `exec_server_message` | `ExecServerMessage` | 请求客户端执行本地工具。 |
| `exec_server_control_message` | `ExecServerControlMessage` | 控制已经发出的客户端执行。 |
| `conversation_checkpoint_update` | `ConversationStateStructure` | 更新客户端持有的会话 checkpoint。 |
| `kv_server_message` | `KvServerMessage` | 请求客户端读取或写入 blob。 |
| `interaction_query` | `InteractionQuery` | 请求用户或客户端作出交互决策。 |
## 16. `InteractionUpdate`
功能:承载模型输出和一次 turn 中的增量状态。
`message``oneof`。当前主要消息如下。
### 16.1 文本和思考
| 分支 | 参数 | 功能 |
| --- | --- | --- |
| `text_delta` | `text``is_server_notice` | 返回可展示文本增量。 |
| `thinking_delta` | `text``thinking_style?` | 返回思考文本增量及展示样式。 |
| `thinking_completed` | `thinking_duration_ms` | 表示思考阶段结束。 |
### 16.2 工具调用
| 分支 | 参数 | 功能 |
| --- | --- | --- |
| `tool_call_started` | `call_id``tool_call``model_call_id` | 宣布工具调用已经建立。 |
| `partial_tool_call` | `call_id``tool_call``args_text_delta``model_call_id` | 在参数尚未完整时返回部分 ToolCall。 |
| `tool_call_delta` | `call_id``tool_call_delta``model_call_id` | 返回 Shell、Task、Edit 或环境替换的增量。 |
| `tool_call_completed` | `call_id``tool_call``model_call_id` | 表示工具调用已经得到终态结果。 |
| `shell_output_delta` | `stdout/stderr/start/exit` | 返回 Shell 进程输出和生命周期增量。 |
同一次工具调用的这些消息必须使用相同的 `call_id`;同一次模型调用产生的工具事件应使用相同的 `model_call_id`
### 16.3 摘要和结束
| 分支 | 参数 | 功能 |
| --- | --- | --- |
| `summary_started` | 无字段 | 表示摘要阶段开始。 |
| `summary` | `summary` | 返回摘要文本。 |
| `summary_completed` | `hook_message?` | 表示摘要阶段完成,并可携带后续 Hook 信息。 |
| `turn_ended` | token 统计字段 | 表示当前 turn 正常结束。 |
`turn_ended` 的 token 字段包括:
- `input_tokens`
- `output_tokens`
- `cache_read_tokens`
- `cache_write_tokens`
- `reasoning_tokens`
### 16.4 保活
| 分支 | 参数 | 功能 |
| --- | --- | --- |
| `heartbeat` | 无字段 | 保持 RunSSE 活跃,不表示业务状态变化。 |
协议还定义 `user_message_appended``token_delta`、step 生命周期、prompt suggestion、branch change、feedback、response comparison 和 context injection state 等更新。
## 17. `ExecServerMessage`
功能:要求客户端执行一项本地能力。
### 17.1 公共字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `id` | `uint32` | 执行桥消息编号,客户端回包必须原样携带。 |
| `exec_id` | `string` | 执行任务标识,流式消息应保持一致。 |
| `span_context` | `SpanContext?` | 可选的分布式追踪上下文。 |
| `accept_hook_additional_contexts` | `bool?` | 是否接受客户端在结果中返回 Hook 附加上下文。 |
| `message` | `oneof` | 工具特定的执行参数。 |
### 17.2 主要执行分支
| 分类 | 分支 | 功能 |
| --- | --- | --- |
| 文件 | `read_args``write_args``delete_args` | 读取、写入或删除文件。 |
| 搜索 | `grep_args``ls_args` | 搜索文本、匹配路径或列出目录。 |
| 诊断 | `diagnostics_args` | 获取编辑器或项目诊断。 |
| Shell | `shell_stream_args` | 启动流式 Shell 命令。 |
| Shell | `write_shell_stdin_args``force_background_shell_args` | 向 Shell 写入输入或切换后台执行。 |
| MCP | `mcp_args``list_mcp_resources_exec_args``read_mcp_resource_exec_args` | 调用 MCP 工具或读取 MCP 资源。 |
| Hook | `execute_hook_args` | 请求客户端执行 Agent Hook。 |
| 子 Agent | `subagent_args` | 请求客户端启动子 Agent。 |
协议还定义普通 Shell、后台 Shell 创建、RequestContext、Fetch、Computer Use、allowlist 预检、Git diff、Pi 工具、会话搜索和 Agent Store 冲突等执行分支。
### 17.3 回包规则
客户端返回 `ExecClientMessage``ExecClientControlMessage` 时:
- `id` 必须与请求一致。
- 如果存在 `exec_id`,应与请求一致。
- 流式执行可以返回多条数据消息。
- 最终结果、`throw` 或明确终态用于结束本次执行关联。
## 18. `ExecServerControlMessage`
功能:控制此前已经发出的 Exec 请求。
当前协议分支:
| 分支 | 参数 | 功能 |
| --- | --- | --- |
| `abort` | `id` | 请求客户端终止对应的 Exec。 |
`abort.id` 对应 `ExecServerMessage.id`,不是 `tool_call_id`
## 19. `InteractionQuery`
功能:请求用户或客户端完成不能由模型单独决定的交互。
### 19.1 公共字段
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `id` | `uint32` | 交互编号,响应必须原样携带。 |
| `query` | `oneof` | 具体交互内容。 |
### 19.2 主要查询分支
| 分支 | 主要参数 | 功能 |
| --- | --- | --- |
| `ask_question_interaction_query` | `args``tool_call_id` | 向用户展示一个或多个问题。 |
| `create_plan_request_query` | `args``tool_call_id` | 请求客户端创建或保存计划。 |
| `web_search_request_query` | `args` | 请求批准 Web Search。 |
| `web_fetch_request_query` | `args``skip_approval``smart_mode_approval` | 请求批准或执行 Web Fetch。 |
| `switch_mode_request_query` | `args.target_mode_id``explanation?``tool_call_id` | 请求切换 Agent 模式。 |
协议还定义 VM 环境、PR 管理、MCP Auth、图片生成、环境替换和 SCM 连接查询。
### 19.3 响应规则
客户端必须用 `InteractionResponse` 返回结果:
- `InteractionResponse.id` 与查询 `id` 相同。
- `result` 分支必须与原查询类型匹配。
- 批准/拒绝型响应应明确选择对应的 `oneof` 分支,不能用空消息代替拒绝。
## 20. `KvServerMessage`
功能:请求客户端提供或保存较大的二进制数据。
| 字段 | 类型 | 功能 |
| --- | --- | --- |
| `id` | `uint32` | KV 操作编号。 |
| `span_context` | `SpanContext?` | 可选追踪上下文。 |
| `get_blob_args` | `GetBlobArgs` | 按 `blob_id` 读取数据。 |
| `set_blob_args` | `SetBlobArgs` | 按 `blob_id` 保存 `blob_data`。 |
客户端使用相同 `id` 返回 `KvClientMessage`
Blob 字段是原始 bytes。协议使用 `blob_id` 引用它们,以避免在主要会话消息中重复传输大型上下文。
## 21. `ConversationStateStructure`
功能:表示可跨请求传递的会话 checkpoint。
该结构既可以由客户端随 `AgentRunRequest` 上传,也可以由服务端通过 `conversation_checkpoint_update` 返回。
| 字段组 | 代表字段 | 功能 |
| --- | --- | --- |
| Prompt | `root_prompt_messages_json` | 根 Prompt 消息,元素以 bytes 保存。 |
| Turn | `turns``turn_timings` | 历史 turn 和耗时。 |
| 工具 | `pending_tool_calls` | 尚未解决的工具调用。 |
| 状态 | `todos``plan``plans` | Todo 和计划状态。 |
| Token | `token_details` | 已用 token、最大 token 和上下文分解。 |
| 摘要 | `summary``summary_archive``summary_archives``self_summary_count` | 当前摘要和历史摘要。 |
| 文件 | `file_states``file_states_v2``read_paths` | 会话涉及的文件状态。 |
| 工作区 | `previous_workspace_uris``tracked_git_repo_branches``active_branch_name` | 工作区和 Git 状态。 |
| 模式 | `mode``agent_type` | 当前 Agent 模式和类型。 |
| 子 Agent | `subagent_states``subagent_threads``subagent_runs_by_parent_tool_call_id``subagent_state_refs` | 子 Agent checkpoint。 |
| 通信进度 | `communicate_update_*` | 长任务进度和最终摘要。 |
| 会话时间 | `conversation_started_timestamp_ms``conversation_started_time_zone` | 会话开始时间。 |
| Goal | `goal_state` | 长期目标状态。 |
注意:多个字段使用 `bytes`,其内部内容通常仍是另一种 protobuf 或 JSON 编码。消费者必须依据字段定义解码,不能把所有 bytes 都当作 UTF-8 文本。
## 22. 消息配对关系
### 22.1 Run
```text
AgentClientMessage.run_request
-> AgentServerMessage.interaction_update (...多条)
-> AgentServerMessage.conversation_checkpoint_update
-> AgentServerMessage.interaction_update.turn_ended
-> stream end
```
### 22.2 Exec
```text
AgentServerMessage.exec_server_message(id, exec_id)
-> AgentClientMessage.exec_client_message(id, exec_id) (...可多条)
-> AgentClientMessage.exec_client_control_message(id) (...可选)
```
### 22.3 Interaction
```text
AgentServerMessage.interaction_query(id, query)
-> AgentClientMessage.interaction_response(id, matching_result)
```
### 22.4 KV
```text
AgentServerMessage.kv_server_message(id, get/set)
-> AgentClientMessage.kv_client_message(id, matching_result)
```
## 23. `oneof` 与可选字段规则
- 同一个 `oneof` 在一条 protobuf 消息中只能设置一个分支。
- 未设置 `optional` 字段和设置为默认值在业务语义上可能不同,消费者需要保留 presence 信息。
- 未识别的 protobuf 字段应按 protobuf 兼容规则保留或忽略,不应导致整条消息无法解析。
- 请求与响应的类型必须匹配,不能只依赖相同的 `id`
- `request_id``conversation_id``model_call_id``tool_call_id``exec_id` 和桥接 `id` 属于不同命名空间,不应互相替代。
- 增量消息只表达追加内容;接收方不应把 delta 当作完整快照覆盖已有内容。
- checkpoint 表达完整状态视图;同类的新 checkpoint 可以替代旧 checkpoint。
## 24. 错误与终止语义
协议需要区分三类结束:
1. 正常业务结束
- 典型信号是 `InteractionUpdate.turn_ended`,随后流结束。
2. 用户或系统取消
- 可能先出现 Exec `abort`,随后 RunSSE 以 canceled 状态结束。
3. 协议、provider 或服务错误
- 可以通过 Connect end-stream error 返回,不一定存在对应的 `AgentServerMessage.oneof` 分支。
因此,客户端不能仅凭“流关闭”判断正常完成;还需要结合最后一条业务消息和流终止状态。
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
+414
View File
@@ -0,0 +1,414 @@
# cursor-byok 当前架构文档
> 基线:当前工作树,而不是历史提交或产品宣传文档。
>
> 更新时间:2026-08-10
## 1. 结论先行
当前项目是一个单进程、本地优先的 Wails 桌面应用。Go 进程同时承担三类职责:
1. 通过本机 App Host 向 Vue WebView 提供产品控制面;
2. 启动一个 Cursor Runtime,包含 Cursor Host、MITM 代理和 Cursor IDE 系统设置注入;
3. 把 Cursor 的本地协议请求转换为用户配置的 OpenAI/Anthropic 兼容模型请求。
当前主进程只注册一个 `cursor-default` Runtime。旧架构中的 Cursor 账号控制面、广告服务和广告资源路由已经从当前工作树移除,不应再作为现状组件绘制。`cursor-tab-server` 仍是独立的命令行程序,不由桌面进程创建。
## 2. 系统上下文
```mermaid
flowchart LR
User["用户"] --> UI["Vue 3 WebView"]
Cursor["Cursor IDE"] --> Proxy["本机 MITM Proxy"]
UI -->|同源 Connect-Web| Host["App Host\n127.0.0.1:随机端口"]
Proxy -->|四条白名单接口| CursorHost["Cursor Host\n本机 BackendListenAddr"]
Proxy -->|非白名单请求原样回源| CursorCloud["Cursor 官方服务"]
CursorHost -->|统一消息与流事件| Provider["Provider Router"]
Provider -->|兼容 HTTP/SSE| ModelAPI["用户配置的模型 API"]
Host --> AppService["AppService ConnectRPC"]
AppService --> Runtime["Supervisor / Runtime"]
AppService --> Test["Model Test Manager"]
AppService --> Metrics["Usage JSON 读取"]
AppService --> Desktop["Wails Desktop Controller"]
```
### 2.1 进程内边界
```text
main.go
└─ startup.Run(组合根)
├─ platform/desktop Wails 窗口、托盘和系统动作
├─ backend/app 产品控制面、快照和 Watch
├─ backend/cursor Cursor Runtime、协议循环和模型链路
├─ proxy MITM、证书和请求转发
├─ modeltest 模型列表与连通性测试
├─ historymetrics usage.json 统计读取
├─ updater 更新检查、下载和安装
└─ platform/* 文件系统、系统代理、证书与平台适配
```
`backend/app` 只依赖产品级端口(配置、Runtime、模型测试、指标、更新、桌面动作);具体实现由 `internal/startup` 的适配器注入。Cursor 协议实现位于 `backend/cursor`,不能让前端或 AppService 直接依赖其 Protobuf/Provider DTO。
## 3. 组合根与启动顺序
`internal/startup/bootstrap.go` 是唯一组合根,拥有长期资源并决定释放顺序。
```mermaid
sequenceDiagram
autonumber
participant M as main.go
participant S as startup.Run
participant FS as filesystem
participant CR as Cursor Runtime
participant AP as AppService
participant H as App Host
participant D as Wails Controller
participant SV as Supervisor
participant U as Updater
M->>S: Run(嵌入的前端资源和图标)
S->>FS: EnsureDataRoot()
S->>S: 初始化统一 HTTP Transport 和 CA Manager
S->>CR: newCursorRuntime()
Note over CR: config.yaml + Config Manager\nagent-state.db + Runner + Provider Factory\nCursor Host + Proxy 工厂
S->>SV: Register(cursor-default)
S->>AP: NewService(配置/Runtime/模型/指标/更新/桌面端口)
S->>H: NewHost(前端资源 + AppService Handler)
S->>H: Start() 监听回环随机端口
S->>D: Run(BootstrapURL, ModelConfigURL, 托盘动作)
D-->>S: ApplicationStarted
S->>U: Start()
S->>SV: Start(cursor-default)
SV->>CR: StartProxy()
CR->>CR: 启动 Cursor Host
CR->>CR: 健康检查,最长 15 秒
CR->>CR: 创建/启动 MITM Proxy
CR->>CR: 安装 CA 并写入 Cursor IDE 代理设置
CR-->>SV: 返回代理 endpoint
D-->>S: OnShutdown
S->>U: Shutdown()
S->>AP: Close(),取消产品 Watcher
S->>SV: Shutdown(),逆序停止 Runtime
SV->>CR: StopProxy()
CR->>CR: 停止 Proxy、清理 IDE 设置、停止 Cursor Host
S->>CR: Close(),关闭 Agent Module 和 SQLite
S->>H: Stop()
```
启动过程失败时,Runtime 按已完成步骤逆序回滚。Supervisor 对同一个 Runtime 使用串行操作锁,重复 Start/Stop 不会并发交错。桌面事件循环退出后,`Run` 仍会再次调用幂等 `shutdown`,确保命令行异常退出和窗口退出都能释放资源。
## 4. 产品控制面
### 4.1 App Host
`internal/backend/app/host.go` 把静态前端和 AppService 放在同一个回环 HTTP 服务中:
- 默认监听 `127.0.0.1:0`,实际端口由系统分配;
- `/bootstrap?token=...` 只允许一次 GET,用一次性 token 换取会话 Cookie
- Cookie 为 `HttpOnly``SameSite=Strict`,后续请求必须携带;
- 请求还要通过 Host Origin 校验,只允许空 Origin 或当前回环 Host
- SPA 未命中静态文件时回退到 `/`
- 当前只挂载 `/app.v1.AppService/`
### 4.2 AppService API 分组
定义文件为 `internal/backend/app/proto/app_v1.proto`,当前 API 可按职责分为:
| 分组 | RPC |
| --- | --- |
| 首屏与状态 | `Bootstrap``Watch` |
| 配置 | `LoadConfig``SaveConfig` |
| Runtime | `ListRuntimes``GetRuntime``StartRuntime``StopRuntime``RestartRuntime` |
| 模型 | `TestModelAdapter``GetModelAdapterTestResults``FetchModelAdapterModels` |
| 指标与应用信息 | `GetHomeMetrics``GetAppInfo` |
| 桌面动作 | `OpenPath``OpenExternal``SetLocale``ControlWindow` |
| 更新 | `CheckForUpdates``InstallReadyUpdate` |
Service 层只做请求校验、端口调用、领域错误到 Connect 错误的映射和 DTO 转换。配置、Runtime、测速和更新的并发/持久化责任分别留在对应实现中。
### 4.3 快照与 Watch
服务端 `eventHub` 为事件分配全局递增 `revision``Watch` 每次连接先发送完整 `BootstrapResponse` 快照,随后发送配置、Runtime、模型测试和更新事件。慢订阅者无法及时消费时会被断开,客户端通过重连重新获取快照,而不是在服务端保留无限事件队列。
```mermaid
sequenceDiagram
participant V as Vue 状态层
participant W as watchCore
participant T as Connect Transport
participant H as App Host
participant A as AppService
participant E as eventHub
V->>W: subscribeAppEvents(listener)
W->>T: Watch(afterRevision=0)
T->>H: 同源二进制 Connect 请求
H->>A: Watch()
A-->>W: Snapshot(revision=N)
W->>V: 应用完整快照
A->>E: 配置/Runtime/测速/更新变化
E-->>W: AppEvent(revision>N)
W->>V: 应用增量事件
Note over W: 断流后按 250ms~5s 指数退避重连;\nBigInt revision 去重,重连首包强制视为快照
```
前端另外保留 `localStorage` 作为启动缓存,但后端配置和 Runtime 快照才是运行时事实来源。当前 `bootstrapAppState` 仍会分别调用配置、测速、版本、Runtime 和指标 RPC;Watch 流用于持续同步变化。
## 5. 前端结构
```text
frontend/src/
main.js Vue、路由、i18n 和初始状态启动
layouts/MainLayout.vue 桌面主布局
views/Home.vue 服务状态、首页指标和更新入口
views/Config.vue 通用应用配置
views/ModelConfig.vue 模型渠道管理
components/ 模型编辑、测速卡片、指标卡和 UI 基础组件
rpc/ Connect-Web Transport、App Client、Watch 重连
services/clientApi.js 页面语义到 RPC 的薄封装
state/ 响应式状态、配置规范化、用户动作和派生视图
i18n/ zh-CN/en-US/ja-JP/ru-RU 运行时国际化
```
模型配置编辑器支持 OpenAI/Anthropic 类型、端点、密钥、模型 ID、推理/思考参数、额外 JSON 参数、自定义请求头、排序、复制、删除、批量测速和供应商模型列表拉取。保存前由前端校验,再通过 `SaveConfig` 完整提交;后端 `configAdapter` 只合并 AppService 定义的字段。
## 6. Runtime 与 MITM
### 6.1 Supervisor 状态
通用 Runtime 状态为 `stopped → starting → running → stopping → stopped`,失败进入 `failed`。当前注册项为:
```text
id: cursor-default
kind: cursor
capabilities: agent, models, proxy
```
`RuntimeDescriptor.Endpoint` 对前端只暴露代理 endpoint。前端的 `runtimeToLegacyState` 把它投影为 `backendRunning/proxyRunning/serviceRunning` 等旧页面字段,因此 UI 看到的是兼容视图,而不是 Cursor Runtime 的全部内部状态。
### 6.2 Cursor Runtime 内部步骤
```mermaid
stateDiagram-v2
[*] --> stopped
stopped --> starting: Supervisor.Start
starting --> host_ready: Cursor Host Start + HealthCheck
host_ready --> proxy_ready: 创建并启动 MITM Proxy
proxy_ready --> running: CA/IDE 设置 Apply 成功
starting --> failed: 配置、监听或健康检查失败
host_ready --> failed: Proxy 创建/启动失败
proxy_ready --> failed: IDE 设置失败
running --> stopping: Supervisor.Stop
stopping --> stopped: 清理 Proxy + IDE + Host 成功
stopping --> failed: 清理失败
```
Host 使用配置中的 `BackendListenAddr`Proxy 使用 `ProxyListenAddr`。代理目标固定为本机 Cursor Host;Runtime 明确拒绝通过外部接口绕过 Host 修改 `baseURL`。若 Proxy 监听地址发生变化,必须先停止运行中的服务,再创建新代理实例。
### 6.3 请求分流
`internal/backend/cursor/routes/routes.go` 是当前路由事实源。仅当请求同时满足以下条件时,MITM 才把请求转发到本机 Cursor Host
- CONNECT 目标是 `cursor.sh` 或其子域名;
- 请求方法是 POST
- 路径属于四个白名单接口:
```text
/aiserver.v1.AiService/AvailableModels
/aiserver.v1.AiService/GetUsableModels
/aiserver.v1.BidiService/BidiAppend
/agent.v1.AgentService/RunSSE
```
其余请求保持原请求回源,代理不读取 body、不改写 URL/headers。MITM 使用内置 CA 动态签发目标站点证书,并缓存按主机生成的证书;HTTP 客户端经过 `netproxy.NewTransport`,统一遵守环境变量和系统代理设置,同时绕过 localhost/127.0.0.1/::1。
## 7. Cursor Agent 执行链
```mermaid
sequenceDiagram
autonumber
participant C as Cursor IDE
participant P as MITM Proxy
participant H as Cursor Host
participant D as CursorDialect
participant R as Agent Runner
participant L as loop 状态机
participant DB as SQLite Store
participant F as Provider Factory
participant A as Provider Adapter
participant API as 模型 API
participant B as Broker
C->>P: POST BidiAppend(request_id, append_seqno)
P->>H: 转发本机白名单接口
H->>D: DecodeBidi
D-->>R: InputEnvelope(Start / Cancel)
R->>DB: FindInputCommit + Load(conversation)
R->>L: TransitionState(Start)
L-->>R: CommandCallLLM + 新消息历史
R->>DB: CommitTransition(CAS + planned llm_call)
R->>F: ForModel(model, conversation, request)
F-->>R: 统一 LLM 客户端
R->>A: Call(RequestMessages)
A->>API: OpenAI/Anthropic 流式请求
API-->>A: SSE/chunk
A-->>R: ResponseEvent(当前桥接主要是文本/usage/Done/Error
R->>L: TransitionState(LLMEvent)
R->>B: Publish(request_id, 编码前的事件)
C->>P: POST RunSSE(request_id)
P->>H: 转发本机白名单接口
H->>B: Next(request_id, index)
B-->>C: AgentServerMessage 流
R->>DB: CommitTransition(最终助手消息 + llm_call)
B-->>C: end=true
```
### 7.1 协议入口实际支持范围
- `BidiAppend` 解码 `AgentClientMessage`;当前 Dialect 接受用户消息启动回合和取消动作,但取消消息没有携带 `ConversationID`,进入 Runner 后会落到缺少会话标识的错误路径。
- `RunSSE``request_id` 从 Broker 顺序消费,直到结束事件或错误。
- `AvailableModels`/`GetUsableModels` 从配置渠道生成 Cursor 需要的模型目录。
- Runner 对同一 `conversationID` 加互斥锁;输入按 `conversationID + inputID` 去重。
- 状态提交使用 SQLite version CAS,模型调用的 planned/final 事实与会话提交放在同一事务边界。
### 7.2 当前工具链边界
当前代码已经定义了工具、thinking、图片、usage 和供应商 tool-call 事件的数据模型,OpenAI/Anthropic 适配器也包含 thinking/工具调用流解析与参数累积逻辑。但桌面主链路仍有两个明确限制:
1. `internal/backend/cursor/provider/provider.go` 创建 `StreamRequest` 时把 `Tools` 固定为 `nil`,并且只把文本、思考内容和工具结果文本投影到 Provider Message,助手 ToolCall 不会进入上游请求;
2. Provider bridge 当前只消费 `ModelEventKindTextDelta``ModelEventKindTurnFinished`,没有把 thinking/tool 事件转换成 `ResponseEvent``CursorDialect` 虽定义了对应编码分支,实际主链路拿不到这些事件,`Runner.runCommand` 也没有执行 `CommandCallClient`
因此当前“有效运行闭环”是用户文本生成、usage、完成和错误收口;thinking、工具调用和取消属于已建模或已接入口但尚未形成可靠端到端闭环的扩展面,不能在架构图中标成已完成能力。
## 8. 模型 Provider 层
```text
Runner
→ provider.Factory.ForModel
→ llm/adapter.Router
→ store/config.Manager.SelectChannelForModel
→ OpenAIAdapter 或 AnthropicAdapter
→ 用户配置的 BaseURL + API Key
```
Router 根据 Cursor 请求中的模型 ID 选择渠道,并注入:
- provider 类型、BaseURL、API Key、真实上游模型 ID
- OpenAI Responses/Chat Completions 端点和推理强度;
- Anthropic thinking/max_tokens/额外参数;
- 自定义请求头、provider 流空闲超时、上下文窗口和输出限制。
适配器负责请求体构造、SSE 解码、thinking 标签/签名处理、工具调用参数增量、usage 归一化、重试和空闲超时。`modeltest.Manager` 是独立的测试通道,不复用 Agent Runner 的会话状态。
## 9. 配置、存储和数据流
### 9.1 配置边界
配置文件为 `~/.cursor-local-assistant-v2/config.yaml``store/config.Manager` 负责规范化、原子快照、保存通知和热加载;`startup/config_adapter.go` 把内部配置投影为 AppService 的 `UserConfig`
AppService 公开的配置包括日志开关、provider 流空闲超时、模型渠道和首页缓存命中率口径。监听地址、运行时私有字段由 Cursor 配置管理器保留;前端仍有少量旧字段缓存,但它们不属于当前 AppService Protobuf 合同。
### 9.2 SQLite 事实模型
数据库路径为 `~/.cursor-local-assistant-v2/history/agent-state.db`,当前迁移创建:
| 表 | 作用 |
| --- | --- |
| `conversations` | 版本化会话状态、消息历史和等待客户端信息 |
| `input_commits` | 输入幂等提交和可重放结果 |
| `llm_calls` | 精确请求、请求哈希、消息哈希和调用状态 |
| `client_operations` | 工具客户端操作事实模型(当前执行链尚未完整使用) |
| `stream_diagnostics` | 可选流诊断事件,不回写模型历史 |
模型流期间的 delta 保存在内存中的 `PendingResponse` 和 Broker;只有回合收口后的助手消息才追加到 `ConversationState.Messages`。这是保持历史 append-only 和 provider prompt cache 稳定性的关键约束。
### 9.3 文件与平台资源
| 路径 | 内容 |
| --- | --- |
| `config.yaml` | 产品/模型配置 |
| `history/agent-state.db` | Agent 会话和调用事实 |
| `history/usage.json` | 首页用量摘要 |
| `logs/app-YYYY-MM-DD.log` | 按本地日期切换的结构化日志文件,权限 0600 |
| `data/ca.crt` | 注入系统和 Cursor 的 CA 文件 |
| Cursor `state.vscdb` | Runtime 启动时同步本地模拟用户信息并关闭绕过代理的实验开关 |
## 10. 桌面外壳、更新与观测
Wails `Controller` 只负责窗口、托盘、外部浏览器、白名单目录和语言同步,不承载模型业务。主窗口默认加载 App Host 的 bootstrap URL;模型配置窗口加载同一 Host 的 `/model-config` 路由。
更新管理器在 Wails 应用启动后开始后台检查,状态通过 AppService Watch 的 `UpdateChanged` 事件到达前端;安装动作委托平台 Installer,完成后通过桌面控制器退出应用。
日志层当前使用 `slog` + `charm.land/log/v2`:控制台输出彩色信息日志,文件输出 logfmt;标准库 `log` 被转接到统一门面。代理连接/TLS/转发错误带有按错误特征的时间窗口限流,避免大量重复错误淹没日志。
## 11. 生命周期和失败处理
```mermaid
stateDiagram-v2
[*] --> stopped
stopped --> starting: StartRuntime
starting --> running: Host ready + Proxy ready + IDE Apply
starting --> failed: 任一步失败
running --> stopping: StopRuntime / App shutdown
stopping --> stopped: Proxy stop + IDE Clear + Host stop
stopping --> failed: 清理失败
failed --> starting: 重试启动
failed --> stopped: 后续停止完成清理
```
启动预算和回滚策略:
- Cursor Host 单次健康检查超时 1 秒,整体等待预算 15 秒;
- Runtime 启动失败时停止已经启动的 Proxy 和 Host
- 停止顺序为 Proxy → 清理 Cursor IDE/system proxy → Cursor Host
- 进程退出时再关闭 Transport Module、SQLite、Supervisor、Updater 和 App Host
- Provider 流、Agent 任务和 Broker 在 Module.Close 时通过运行域 context 统一取消。
## 12. 当前工作树中必须关注的事实与风险
### 已确认的现状
- 当前主进程只有 AppService 控制面;账号、广告相关 Go 包、Proto、前端组件和 RPC 客户端均已删除。
- 当前 Runtime 注册表仍为可扩展的多 Runtime 抽象,但实际只注册 Cursor 一个实例。
- Provider 适配器覆盖 OpenAI Responses/Chat Completions 和 Anthropic Messages,支持 thinking、usage、部分工具事件解析。
- Agent Dialect/Runner 仍是受限 MVP:主要闭环为用户消息 → 模型流 → 文本下行 → usage/完成或错误;thinking、工具结果和取消仍有桥接缺口。
- 首页指标适配器只读取 `history/usage.json`;当前仓库没有对应写入器,新安装环境会得到空指标,除非该文件由外部或尚未合入的链路生成。
- `cursor-tab-server` 是独立 Go module,使用固定 Cursor Tab 上游路径和 YAML token,不共享桌面进程的 Host、Cookie 或 Runtime。
### 当前验证阻塞
本次分析执行了后端相关测试,但当前工作树的 `go.mod` 已移除 `charm.land/log/v2` 直接依赖,而 `internal/logger/logger.go` 仍导入该包,因此 `go test` 在编译 cursor/proxy/startup 相关包时失败。该依赖不一致属于工作树现状,文档没有擅自修改。
## 13. 演进建议
1. 先打通 Provider → Runner → Dialect 的工具闭环:传递 `Tools`、保留助手 ToolCall、发布 Cursor 工具事件、接受工具结果,再驱动下一轮 `CommandCallLLM`
2.`AppService` 错误边界引入稳定领域错误类型,减少当前基于错误文本的 Connect code 判断。
3. 将前端旧的监听地址缓存字段从状态合同中清理,避免用户误以为可以通过产品配置修改 Runtime 拓扑。
4. 为 Runtime、Proxy、Runner、Provider 调用统一注入 request/conversation/call trace ID,打通结构化日志、SQLite 和性能测试结果。
5. 修复依赖锁定后再执行 `go test ./...`、前端 RPC 测试和跨平台构建;协议生成任务继续以 `build/Taskfile.yml` 为唯一入口。
## 14. 关键源码索引
| 主题 | 入口 |
| --- | --- |
| 进程入口 | [`main.go`](../main.go) |
| 组合根 | [`internal/startup/bootstrap.go`](../internal/startup/bootstrap.go) |
| Runtime 管理 | [`internal/startup/supervisor.go`](../internal/startup/supervisor.go) |
| AppService 合同 | [`internal/backend/app/proto/app_v1.proto`](../internal/backend/app/proto/app_v1.proto) |
| AppService 实现 | [`internal/backend/app/service.go`](../internal/backend/app/service.go) |
| App Host 认证与 SPA | [`internal/backend/app/host.go`](../internal/backend/app/host.go) |
| App 事件与快照 | [`internal/backend/app/events.go`](../internal/backend/app/events.go)、[`internal/backend/app/snapshot.go`](../internal/backend/app/snapshot.go) |
| Cursor Host | [`internal/backend/cursor/host.go`](../internal/backend/cursor/host.go) |
| Cursor Runtime 生命周期 | [`internal/backend/cursor/runtime_lifecycle.go`](../internal/backend/cursor/runtime_lifecycle.go) |
| MITM 分流 | [`internal/proxy/router.go`](../internal/proxy/router.go)、[`internal/proxy/passthrough.go`](../internal/proxy/passthrough.go) |
| 路由事实源 | [`internal/backend/cursor/routes/routes.go`](../internal/backend/cursor/routes/routes.go) |
| Agent 传输 | [`internal/backend/cursor/transport/handler.go`](../internal/backend/cursor/transport/handler.go)、[`internal/backend/cursor/transport/cursor_dialect.go`](../internal/backend/cursor/transport/cursor_dialect.go) |
| Agent 状态机 | [`internal/backend/cursor/loop/transition.go`](../internal/backend/cursor/loop/transition.go) |
| Agent 协调器 | [`internal/backend/cursor/agentrun/runner.go`](../internal/backend/cursor/agentrun/runner.go) |
| Provider 路由 | [`internal/backend/cursor/llm/adapter/router.go`](../internal/backend/cursor/llm/adapter/router.go) |
| Provider 桥接 | [`internal/backend/cursor/provider/provider.go`](../internal/backend/cursor/provider/provider.go) |
| SQLite 持久化 | [`internal/backend/cursor/store/sqlite.go`](../internal/backend/cursor/store/sqlite.go)、[`internal/backend/cursor/store/conversations.go`](../internal/backend/cursor/store/conversations.go) |
| 前端 RPC 与重连 | [`frontend/src/rpc/watchCore.js`](../frontend/src/rpc/watchCore.js)、[`frontend/src/services/clientApi.js`](../frontend/src/services/clientApi.js) |
| 前端状态 | [`frontend/src/state/appState.js`](../frontend/src/state/appState.js)、[`frontend/src/state/appActions.js`](../frontend/src/state/appActions.js) |
| 日志与按日文件 | [`internal/logger/logger.go`](../internal/logger/logger.go)、[`internal/logger/daily_file.go`](../internal/logger/daily_file.go) |
| 构建与协议生成 | [`Taskfile.yml`](../Taskfile.yml)、[`build/Taskfile.yml`](../build/Taskfile.yml) |