mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 19:47:10 +08:00
feat(console): add initial console for Cursor BYOK with provider management and LLM call tracking
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
claude-server.tar
|
||||
.DS_Store
|
||||
dist
|
||||
.task
|
||||
bin
|
||||
@@ -8,8 +9,12 @@ logs.zip
|
||||
frontend/bindings
|
||||
dist
|
||||
node_modules
|
||||
*.tsbuildinfo
|
||||
cursor-server.tar
|
||||
/cursor-server/target/
|
||||
/cursor-server/*.db
|
||||
/cursor-server/*.db-shm
|
||||
/cursor-server/*.db-wal
|
||||
server-node/cursor.tar
|
||||
server-go/cursor.tar
|
||||
server-go/log/
|
||||
|
||||
+507
-136
@@ -47,8 +47,8 @@ BidiAppend(request_id, append_seqno, data)
|
||||
| ID | 作用域 | 用途 |
|
||||
| --- | --- | --- |
|
||||
| `conversation_id` | 跨 Turn | 持久会话、最新 checkpoint、子会话 |
|
||||
| `request_id` | 一次 Run | 关联 RunSSE 与 BidiAppend |
|
||||
| `run_id` | 一次执行 | 当前样本中常与 `request_id` 相同,但不应假设永远相同 |
|
||||
| `request_id` | 一次具体执行/传输尝试 | 关联 RunSSE 与 BidiAppend;Cursor adapter 以它创建内部 RunId |
|
||||
| `run_id` | Cursor 逻辑 Run 元数据 | 普通样本中常与 `request_id` 相同;队列/子代理恢复时可能跨新 request 复用,不能作为执行表主键 |
|
||||
| `append_seqno` | 单个 request | Bidi 上行排序与去重 |
|
||||
| KV `id` | 单个 request | 配对 KV request/result |
|
||||
| Exec `id` | 单个 request | 配对本地执行 request/result |
|
||||
@@ -79,6 +79,8 @@ BidiAppend(request_id, append_seqno, data)
|
||||
- `requested_model`、候选子 Agent 模型和模型覆盖;
|
||||
- 客户端能力位。
|
||||
|
||||
`conversation_state` 的字段存在性不能用来判断是否已有历史。Cursor 在新对话中也会发送一个已分配但 roots 为空的 state;它表示空历史基线,首份 checkpoint 才写入 system root。只有 roots 非空的恢复历史才要求其中恰好存在一个 system prompt root。
|
||||
|
||||
主会话首轮模型为 `grok-4.6`,参数为 `effort=high`、`fast=true`。首轮引用的 rules 为 1,114 字节、skills 为 5,207 字节、MCP 为 28,089 字节;空 subagents 使用 SHA-256 空串地址:
|
||||
|
||||
```text
|
||||
@@ -812,7 +814,7 @@ LLM 本身不保存会话。Loop 引擎反复把当前完整上下文投射成
|
||||
```text
|
||||
Provider SSE
|
||||
→ OpenAI Chat / Responses / Anthropic Adapter
|
||||
→ Canonical ResponseEvent
|
||||
→ 统一 ModelEvent
|
||||
→ Loop State Machine
|
||||
→ Cursor AgentServerMessage
|
||||
→ Connect RunSSE
|
||||
@@ -825,13 +827,36 @@ start
|
||||
text_start / text_delta / text_end
|
||||
thinking_start / thinking_delta / thinking_end
|
||||
toolcall_start / toolcall_delta / toolcall_end
|
||||
done(stop | length | toolUse | error | aborted)
|
||||
error
|
||||
done(stop | length | toolUse)
|
||||
stream_error / cancellation
|
||||
```
|
||||
|
||||
其中 `error` 与 cancellation 是流错误和 Run 终态,不伪装成成功事件序列中的 `done(error/aborted)`;`length/incomplete` 也不是正常完成。
|
||||
|
||||
模型端点差异只留在 Adapter。Loop、Blob、checkpoint 和 Cursor transport 不依赖具体 provider。
|
||||
|
||||
### 16.3 RunSSE 的线格式
|
||||
### 16.3 上游调用的最小知识边界
|
||||
|
||||
当前自然的数据流是:
|
||||
|
||||
```text
|
||||
selected revision
|
||||
→ 纯 model/projection:canonical messages → typed history
|
||||
→ PromptSpec + ModelSpec + typed history = ModelRequest
|
||||
→ ModelInvocation(call_id + cancellation + ModelRequest)
|
||||
→ 固定 Provider adapter 的 request projector
|
||||
→ HTTP response headers → SSE decoder
|
||||
→ ModelEvent
|
||||
→ 严格 ModelCycle
|
||||
```
|
||||
|
||||
`ModelRequest` 只保存可重放输入,不含 request id、时间、`model_call_id` 或 Cursor mode;调用 ID 和取消属于 `ModelInvocation`。每一轮都显式发送完整请求,不使用 `previous_response_id` 等服务端隐式会话作为上下文事实源。相同 PromptSpec、ModelSpec 和 selected revision 必须产生可比较的同一请求;下一 revision 只扩展旧历史前缀。
|
||||
|
||||
Provider adapter 只认识 typed text/image/assistant/call/result 和自己的 endpoint JSON,不读 SQLite,不发 Cursor protobuf,不构造 checkpoint,也不执行工具。Chat、Responses、Anthropic 分别保存并回传自己的 reasoning replay state;跨端点不解码、不伪造。usage 的累计语义由各 adapter 在内部消化,对公共 ModelCycle 只发一次端点本轮最终可信总量;第二个 Usage 是协议错误,不能静默覆盖前值。缺失时保持缺失。
|
||||
|
||||
同一个 CancellationToken 必须同时覆盖等待 HTTP 响应头和读取 SSE。若只在 SSE 建立后监听取消,新 Run 会无法及时中断仍卡在上游握手中的旧 Run。HTTP 非成功状态、裸 EOF、未闭合 content/tool block、`length/incomplete` 都是显式失败,不能 fallback 成正常 Done。
|
||||
|
||||
### 16.4 RunSSE 的线格式
|
||||
|
||||
`RunSSE` 虽然使用流式 HTTP,但正文不是浏览器式文本 `data: ...\n\n`。它是 Connect 流式二进制 envelope:
|
||||
|
||||
@@ -866,30 +891,31 @@ Runtime tag 虽然是实时产生的,但首次追加后也立即成为不可
|
||||
|
||||
`model_call_id` 只是一次模型调用的关联 ID,不是 provider 前缀缓存条件。跨模型时正常构造新请求即可,不应为了复用 `model_call_id` 改写历史。
|
||||
|
||||
PromptSpec 只在单个 Run 内冻结。新 Run 切换模型或模式时,如果 system prompt 内容发生变化,Cursor checkpoint 应以新的内容寻址 Blob 替换 system root,同时复用其余历史 message roots;不能因为旧 system root 文本不同而拒绝对话。该请求自然进入新的模型/Prompt 缓存域,不要求跨模型共享缓存。上游 ModelRequest 始终使用本 Run 的 PromptSpec,旧 system root 只用于恢复时验证历史结构,不进入 canonical messages。
|
||||
|
||||
### 17.2 Tool call/result 的完整性约束
|
||||
|
||||
工具可以并行执行并乱序完成,但下一次模型调用不能看到悬空 tool call。内部状态应把每个调用和结果一对一关联:
|
||||
工具可以并行执行并乱序完成,但下一次模型调用不能看到悬空 tool call。每个完成结果以一组相邻消息原子提交:
|
||||
|
||||
```text
|
||||
ToolBatch
|
||||
├─ slot 0: call0 ↔ result0
|
||||
├─ slot 1: call1 ↔ result1
|
||||
└─ slot 2: call2 ↔ result2
|
||||
assistant(call3) → tool(result3)
|
||||
assistant(call1) → tool(result1)
|
||||
assistant(call0) → tool(result0)
|
||||
```
|
||||
|
||||
只有当前模型产生的 Tool Batch 全部具有可投射结果后,才能构造下一轮 provider request。该约束保证相同 committed state 总能产生相同请求。
|
||||
这里的 pair 顺序就是真实 `completion_seq`,不为恢复原始 call 顺序而阻塞已完成工具。只有当前模型产生的 ToolRound 全部具有可投射结果后,公共 projector 才按 durable ToolRoundId 折叠成一个 assistant batch;assistant 内 calls 恢复 Provider 原始 index,随后的 result messages 保持 completion_seq。该约束保证相同 committed revision 总能产生相同请求。
|
||||
|
||||
抓包中的模型 transcript 采用 AI SDK/OpenAI Chat 风格:
|
||||
|
||||
```text
|
||||
assistant: [call0, call1, call2, call3]
|
||||
tool: result0
|
||||
tool: result1
|
||||
tool: result2
|
||||
tool: result3
|
||||
tool: result2
|
||||
tool: result0
|
||||
```
|
||||
|
||||
真实执行完成顺序为 `result1、result3、result2、result0`,但最终持久化顺序恢复成原始 call 顺序。这里的“一对一”是语义配对和原子投射约束,不要求所有 provider 都使用字面上的 `call0,result0,call1,result1` 排列;具体线格式由端点 Adapter 决定。
|
||||
这里的“一对一”是语义配对、相邻原子提交和整批完整性约束,不要求 result 与 call index 同序;具体线格式由端点 Adapter 决定。
|
||||
|
||||
## 18. Tool 流事件与客户端占位卡片
|
||||
|
||||
@@ -932,7 +958,7 @@ tool name
|
||||
→ execution delta/result mapper
|
||||
```
|
||||
|
||||
## 19. Checkpoint 的时机与单 Tool 回滚
|
||||
## 19. Checkpoint 的时机与 ToolRound 恢复
|
||||
|
||||
### 19.1 Checkpoint 是可恢复提交,不只是 UI 快照
|
||||
|
||||
@@ -963,35 +989,52 @@ checkpoint pending=1
|
||||
|
||||
但抓包中的四工具样本没有把第一个完成结果单独写进 Turn。第一个 `tool_call_completed` 后发出的 checkpoint 仍只有 Thinking 和 Assistant 两个 Step;等四个工具全完成后,四个 Tool Step 才一起进入新 Turn。因此当前样本能恢复到“工具批次正在执行”,不能恢复到“其中某一个工具已经完成且结果已持久化”。
|
||||
|
||||
### 19.3 服务端目标:单 Tool 粒度
|
||||
### 19.3 服务端目标:忠实实现 ToolRound 粒度
|
||||
|
||||
为了避免崩溃后重复执行写文件、shell、MCP 等有副作用的工具,服务端实现应提高到单 Tool 粒度。每个 call 使用固定 slot,完成状态可以和其他工具交叉:
|
||||
服务端不能以“减少副作用重放”为理由发明抓包中不存在的部分完成 checkpoint。正确提交点只有两个:
|
||||
|
||||
```text
|
||||
slot[0] = call0 pending
|
||||
slot[1] = call1 completed(result1)
|
||||
slot[2] = call2 pending
|
||||
Provider 完整结束为 tools
|
||||
→ 原子保存 ToolRound assistant 与全部有序 calls
|
||||
→ checkpoint(stable roots 不变,pending assistant = 1)
|
||||
→ 执行工具
|
||||
|
||||
每个 Tool Result 到达
|
||||
→ 按真实完成顺序原子追加 assistant(call) → tool(result)
|
||||
→ 中间结果不发布 checkpoint
|
||||
→ 最后一个结果使 ToolRound settled
|
||||
→ Blob SET/ACK
|
||||
→ checkpoint(assistant batch + 全部 results 进入 stable roots,pending = 0)
|
||||
→ 下一轮 LLM
|
||||
```
|
||||
|
||||
建议两个提交点:
|
||||
SQLite 的 durable ToolRound 可以记录单 call 完成状态,用于进程内一致性和诊断;它不是客户端已经持有的恢复点。Cursor 自动恢复只以客户端下一次带回的 eligible checkpoint 为事实,因此 staged 状态中断后会重新执行整批工具。若未来要避免某类副作用重复执行,需要新的 wire 证据或客户端幂等键,不能把部分 ToolResult 塞进当前 checkpoint 语义。
|
||||
|
||||
### 19.4 当前版本复核:exchange 9005 与 Cursor.app
|
||||
|
||||
当前 Cursor `3.16.17` 的完整 exchange `9005` 给出更精确的序列。frame `467/493/504/526/543/565/579/613/625/1643/1644/1645` 的 `(stable roots, pending)` 依次为:
|
||||
|
||||
```text
|
||||
Tool 参数完整
|
||||
→ 持久化 Tool Intent
|
||||
→ Blob 确认
|
||||
→ checkpoint(call=pending)
|
||||
→ 才向客户端发 ExecServerMessage
|
||||
|
||||
Tool Result 返回
|
||||
→ 服务端本地 SQLite 先记 working state
|
||||
→ 生成 Result JSON Blob、Completed Tool Step Blob、新 Turn Blob
|
||||
→ Blob 确认
|
||||
→ checkpoint(call=completed)
|
||||
(31,0) → (31,1) → (35,0)
|
||||
→ (35,1) → (39,0)
|
||||
→ (39,1) → (43,0)
|
||||
→ (43,1) → (48,0)
|
||||
→ (48,1) → (49,0) → (49,0)
|
||||
```
|
||||
|
||||
Checkpoint 可以记录部分完成的 Tool Batch,但 LLM Projector 仍须等待整批 call/result 完整,不能把部分完成状态投射为下一轮模型请求。这样同时满足单 Tool 回滚与 LLM tool protocol 完整性。
|
||||
四个 ToolRound 的 settled checkpoint 都严格早于下一轮首个模型 interaction:`504 < 505`、`543 < 544`、`579 < 580`、`625 < 626`。这证明 settled checkpoint 是继续 Loop 前的 client-state barrier,但不代表存在 wire checkpoint ACK;protobuf 只有 Blob SET 的 `set_blob_result(id)`。
|
||||
|
||||
## 20. Blob 确认、重试与 Checkpoint 送达
|
||||
最终新 Blob 位于 RunSSE frame `1638..1641`,分别是 thinking Step、assistant Step、更新后的 Turn wrapper 和 assistant root JSON;同一 request 的 Bidi `id=122..125` 都返回成功 SET result。RunSSE 随后是 frame `1642` 的 `turn_ended`,再是 `1643..1645` 的 staged、settled、相同 settled 重发。抓包数据库没有保存两条独立 HTTP 流中每个 frame 的统一时间戳,因此不能仅凭 frame index 声称 ACK 与 `turn_ended` 的跨流先后;能够确认的是四个 ACK 均在 RunSSE 结束前到达。实现采用更强且确定的安全屏障:这四个新增 Blob 全部 ACK 后才解除 final state barrier,并发送 `turn_ended`/checkpoint。三份终局 checkpoint 的最后一个 Turn BlobID 相同,因此终局 presentation delta 只能消费一次。
|
||||
|
||||
Cursor.app 的运行代码把 `turn_ended` 前的 checkpoint 标为 `eligible`,之后标为 `ineligible_terminal_turn`。断流恢复会带回最新 eligible state 并改用 `resume_action`;若该 state 含完整 pending assistant,服务端恢复 ToolRound 并先执行工具,不得再次调用 LLM。`pendingToolCallStartedAtMs`、未知 reasoning signature 和旧 Step 时间都必须原样保留。
|
||||
|
||||
这个标记发生在客户端消费帧时,因此 `turn_ended` 与第一份终局 checkpoint 之间存在一个很窄的断流窗口:客户端已见 `turn_ended`,但还没有见 `ineligible_terminal_turn`,此时仍可能用上一份 eligible checkpoint 重试。这不改变协议顺序,也不构成 checkpoint ACK 的理由;服务端只能以下一次 `run_request` 实际带回的 state 为准。
|
||||
|
||||
stable root JSON 的 wire `id` 也不是内部身份:同一 exchange 的四个不同 assistant 工具批次都使用字符串 `"1"`,tool result root 的 `id` 等于 `toolCallId`。内部 MessageId/ToolRoundId 必须从 BlobID、序位和 durable round 产生,不能按 wire id 合并。
|
||||
|
||||
checkpoint 的非 canonical 元数据并非全部冻结。exchange `9005` 的 `read_paths` 随成功 Read 从 11 项增加到 12 项;Todo/Plan/UpdateCurrentStep 也由 typed completion 或 canonical messages 确定性推进。`token_details.used_tokens` 是当前一次完整模型上下文的占用,不等于整个 Turn 的累计 provider input。服务端以最后一次 provider 调用返回的 `input_tokens + output_tokens` 更新它;`max_tokens` 来自 Cursor 既有 checkpoint 或请求模型的 `context` 参数。breakdown 的分类值是展示估算,不冒充 provider usage,但其 token 合计必须严格等于权威 `used_tokens`。
|
||||
|
||||
## 20. Blob 确认与 Checkpoint 送达
|
||||
|
||||
### 20.1 Blob SET 的确认语义
|
||||
|
||||
@@ -1012,7 +1055,7 @@ message SetBlobResult {
|
||||
|
||||
`SetBlobResult {}` 表示成功,带 `error` 表示失败。当前完整 exchange 372 中 154 个 `set_blob_args` 对应 154 个无错误 `set_blob_result`,成功样本没有发现缺失确认。
|
||||
|
||||
BlobID 是内容哈希,所以重发同一个 `blob_id + blob_data` 是幂等操作。KV `id` 用于匹配一次尝试;同一 Blob 可以在超时后以新的 KV `id` 重试,迟到或重复结果按 BlobID 合并为已确认状态。
|
||||
BlobID 是内容哈希,因此相同内容天然得到相同 ID;但当前会话协议仍把每次 SET 表达为唯一 KV `id` 对应唯一 `set_blob_result`。实现不在超时后生成新 KV id 重试同一 Blob,也不合并迟到尝试。
|
||||
|
||||
服务端应区分:
|
||||
|
||||
@@ -1021,7 +1064,7 @@ Working State:结果已经到达服务端,但客户端 Blob 是否持久化
|
||||
Committed Checkpoint:只引用已经得到成功确认的 Blob
|
||||
```
|
||||
|
||||
如果某个确认暂时没有返回,不应把它立即判为写入失败,也不能发布引用该 Blob 的 checkpoint。继续保持 working state、重试内容寻址写入,并保留上一个 committed checkpoint。
|
||||
如果确认在配置的等待期限内没有返回,当前 checkpoint job 失败,并进入该 Run 的 typed Error/取消生命周期;绝不能发布引用该 Blob 的 checkpoint,也不保存跨 RunSSE working/outbox 等待以后续传。
|
||||
|
||||
候选 checkpoint 不必等待与它无关的所有 Blob,只需要满足:
|
||||
|
||||
@@ -1034,7 +1077,7 @@ Checkpoint C 可以发布
|
||||
|
||||
抓包中 `turn_ended` 后可能先发送一个过渡 checkpoint,随后相同的稳定最终 checkpoint 连续发送两到三次,最后才发送 Connect EndStream。exchange 372 的尾部是 `pending=1` 的过渡 checkpoint,接着两次 `roots=59、pending=0` 的相同最终 checkpoint。正常未断流的 RunSSE 是有序可靠字节流:客户端如果收到了后面的 EndStream,就一定先收到了位于它之前的完整 checkpoint 帧。因此在正常完成路径上,可以断言最终 checkpoint 已经通过 RunSSE 送达客户端,不需要额外 checkpoint ACK 才结束。
|
||||
|
||||
需要严格区分“传输送达”和“应用层确认”:协议没有单独的 `checkpoint_ack`。重复帧说明客户端必须幂等接受相同 checkpoint,也增强了尾部发送的稳健性,但仅凭重复本身不能证明断线之后客户端已经持久化了哪一份状态。断流时应以客户端下一次 `run_request` 实际带回的 checkpoint 为恢复事实,并从服务端 SQLite working/outbox 状态继续同步。
|
||||
需要严格区分“传输送达”和“应用层确认”:协议没有单独的 `checkpoint_ack`。重复帧说明客户端必须幂等接受相同 checkpoint,也增强了尾部发送的稳健性,但仅凭重复本身不能证明断线之后客户端已经持久化了哪一份状态。断流时只以客户端下一次 `run_request` 实际带回的 checkpoint 为恢复事实;服务端不重放旧 RunSSE 帧,也不从不存在的 outbox 猜测客户端状态。
|
||||
|
||||
## 21. Usage 与 Turn 收口
|
||||
|
||||
@@ -1058,7 +1101,32 @@ message TurnEndedUpdate {
|
||||
}
|
||||
```
|
||||
|
||||
最小实现不需要生成 `token_delta`。权威 usage 只信任各 LLM Adapter 从 provider 最终事件读取到的值:不根据文本 delta 自己 tokenize,不推算 cache token,也不推算 reasoning token。provider 未返回的可选字段保持缺失。
|
||||
exchange `9005` 明确包含 378 个 `token_delta`,合计 5022;它们穿插在 thinking、text、tool/exec 流事件之间。该流最终的 `turn_ended` 是 `input=388564、output=5870、cache_read=346112、reasoning=2736`,因此 `token_delta` 既不是 Turn input,也不是最终 output 的逐块拆分。checkpoint 的 `used_tokens` 同时从 35302 前进到 44492,`max_tokens=256000`。三者职责必须分开:
|
||||
|
||||
```text
|
||||
token_delta 生成期间供 Cursor UI 增量刷新
|
||||
checkpoint.token_details 当前上下文占用/上限
|
||||
turn_ended 整个 Run 的 provider 权威累计量
|
||||
```
|
||||
|
||||
通用 provider 端点通常只在流末给出可信 token 数,无法复现 Cursor 私有服务逐 chunk 的估算。当前实现因此在每次模型调用的 terminal Usage 到达时发送一个 `token_delta(output_tokens)`,不根据文本、thinking 或工具参数自行 tokenize;随后用该次调用的 `input_tokens + output_tokens` 更新 checkpoint。这样 UI 会更新,数值仍全部来自 provider,只是刷新粒度为一次模型调用而非每个 chunk。
|
||||
|
||||
同一抓包还证明 breakdown 不是把权威总量按比例平摊。六个非对话分类在所有 checkpoint 中保持固定:
|
||||
|
||||
| id | label | character_count | estimated_tokens |
|
||||
| --- | --- | ---: | ---: |
|
||||
| `system_prompt` | System prompt | 3372 | 920 |
|
||||
| `tools` | Tool definitions | 40174 | 10965 |
|
||||
| `rules` | Rules | 7684 | 2097 |
|
||||
| `skills` | Skills | 6305 | 1720 |
|
||||
| `mcp` | MCP & dynamic tools | 11916 | 3252 |
|
||||
| `subagents` | Subagent definitions | 3413 | 931 |
|
||||
|
||||
`summarized_conversation` 在该样本为零;`conversation` 随消息增长,并取得 `used_tokens` 扣除其他分类估算后的剩余值。例如最终 `used_tokens=44492`,其他分类合计 19885,故 conversation 恰为 24607。实现遵守同一结构:按实际投射内容分别统计 UTF-16 `character_count`;system prompt、静态工具、rules、skills、动态 MCP、subagent 和已有 summary 独立估算;普通 user/assistant/tool 内容进入 conversation;最后由 conversation 吸收权威总量的余数。若分类估算异常超过权威总量,则只按最大余数法压缩非 conversation 分类,保证八类非负且总和始终精确。
|
||||
|
||||
分类边界来自实际数据流而不是工具名猜测:静态 prompt 和 ToolDefinition 由当前 PromptSpec 提供,动态 MCP ToolDefinition 进入 `mcp`,只有 `origin=runtime` 的消息才解析其中明确的 `<rules>`、`<agent_skills>`、`<subagents>`、`<mcp_meta_tools>` 区段;用户正文即使含相似文本也仍属于 conversation。分类估算器按 Cursor/JavaScript 的 UTF-16 字符口径统计,ASCII 使用每字符约 `0.273` token、非 ASCII 使用每 UTF-16 code unit 约 `0.55` token;这只决定分类分布,不改变 provider 权威总量。当前抓包的 `prompt_context_usage_tree` 为空,因此只生成已被证实的八类 breakdown,不编造 tree/node 或 snapshot Blob。
|
||||
|
||||
权威 usage 只信任各 LLM Adapter 从 provider 最终事件读取到的值:不推算 cache token,也不推算 reasoning token。adapter 可读取多个端点累计快照,但必须先汇总并只交付一个 terminal total;公共状态机收到重复 Usage 直接失败。provider 未返回的可选字段保持缺失。
|
||||
|
||||
`turn_ended` 表达整个 Cursor Run/Turn 的汇总,而不是一次 provider 调用。exchange 372 的最终值为:
|
||||
|
||||
@@ -1072,14 +1140,15 @@ reasoning_tokens = 5004
|
||||
|
||||
`input_tokens` 已明显超过单次 256K 上下文,证明它是同一 Run 内多次 LLM 调用的累计值。实现时只对 provider 返回的可信调用总量求和,然后在最终 `turn_ended` 一次汇报。
|
||||
|
||||
最终收口顺序建议为:
|
||||
当前实现的固定收口顺序为:
|
||||
|
||||
```text
|
||||
最终 provider done(stop)
|
||||
→ 最终 text_delta / step_completed
|
||||
→ SET 最终 assistant JSON / Step / Turn Blob
|
||||
→ 构造并 SET 最终 assistant JSON / Step / Turn Blob
|
||||
→ 等待这些新增 Blob 的配对 SET ACK
|
||||
→ turn_ended(整轮可信 usage 总量)
|
||||
→ 等待所引用 Blob 成功确认并发送过渡 checkpoint(如果需要)
|
||||
→ 发送 pending=1 的过渡 checkpoint
|
||||
→ final checkpoint(pending=0)
|
||||
→ 幂等重复 final checkpoint
|
||||
→ Connect EndStream
|
||||
@@ -1186,7 +1255,7 @@ request_id → 一次 Run/传输尝试
|
||||
RunSSE → request_id 的下行通道
|
||||
```
|
||||
|
||||
当前抓包没有 abort/error 尾部,因此异常路径只能作为实现约束:用户打断时取消旧 provider 和尚未继续的工具,提交最后安全 checkpoint,以 canceled/aborted EndStream 结束旧 Run;不能伪造正常成功的 `turn_ended`。单纯的 RunSSE 断线也不等于 Turn 已结束,恢复事实应来自客户端下一次带回的 checkpoint。
|
||||
当前抓包没有 abort/error 尾部,因此异常路径只能作为实现约束:用户打断时取消旧 provider 和尚未继续的工具,保留此前已经发布的最后安全 checkpoint,不再为取消制造新 checkpoint,并以 canceled/aborted EndStream 结束旧 Run;不能伪造正常成功的 `turn_ended`。单纯的 RunSSE 断线也不等于 Turn 已结束,恢复事实应来自客户端下一次带回的 checkpoint。
|
||||
|
||||
## 23. Runtime tag:运行时产生、严格追加一次
|
||||
|
||||
@@ -1291,54 +1360,80 @@ CreatePlan 参数/成功结果 → 更新 current_plan 投影
|
||||
|
||||
## 25. 多模式 Prompt 与 Tool 资产
|
||||
|
||||
`main` 分支已有完整的静态 prompt、模式工具定义和 reminder 模板。已将其中 18 个语言无关资产原样复制到当前分支的 `prompt/`:
|
||||
当前静态资产已经收敛到 `prompt/cursor/`。完整工具 schema 只有根目录一个 catalog,各模式只保存有序 manifest;共享 schema 不在不同模式间复制:
|
||||
|
||||
```text
|
||||
prompt/
|
||||
├─ common_prefix.md
|
||||
├─ agent/ prompt.md + tools.json
|
||||
├─ ask/ prompt.md + tools.json
|
||||
├─ plan/ prompt.md + tools.json + system_reminder.txt
|
||||
├─ debug/ prompt.md + tools.json + initial/continuing reminder
|
||||
├─ multitask/ prompt.md + tools.json
|
||||
├─ subagent/ prompt.md + tools.json
|
||||
├─ compaction/ prompt.md
|
||||
└─ commit/ prompt.md
|
||||
prompt/cursor/
|
||||
├─ tools.json # 完整 schema catalog + Task.subagent variant
|
||||
├─ modes/ # 每个模式的有序工具 manifest
|
||||
│ ├─ agent.json
|
||||
│ ├─ ask.json
|
||||
│ ├─ plan.json
|
||||
│ ├─ debug.json
|
||||
│ ├─ multitask.json
|
||||
│ ├─ subagent.json
|
||||
│ └─ compaction.json
|
||||
├─ agent/
|
||||
│ ├─ prompt.md # 静态 system prompt
|
||||
│ └─ runtime.md # 本模式的 user-role runtime 模板
|
||||
├─ ask/{prompt.md,runtime.md}
|
||||
├─ plan/{prompt.md,runtime.md}
|
||||
├─ debug/{prompt.md,runtime.md}
|
||||
├─ multitask/{prompt.md,runtime.md}
|
||||
├─ subagent/{prompt.md,runtime.md}
|
||||
└─ compaction/{prompt.md,runtime.md}
|
||||
```
|
||||
|
||||
工具数量:
|
||||
|
||||
| 模式 | Tools |
|
||||
| --- | ---: |
|
||||
| Agent | 21 |
|
||||
| Ask | 19 |
|
||||
| Plan | 17 |
|
||||
| Debug | 19 |
|
||||
| Multitask | 21 |
|
||||
| Subagent | 4 |
|
||||
| Agent | 20 |
|
||||
| Ask | 15 |
|
||||
| Plan | 13 |
|
||||
| Debug | 15 |
|
||||
| Multitask | 17 |
|
||||
| Subagent | 20 |
|
||||
| Compaction | 0 |
|
||||
|
||||
Rust 服务端应在启动时加载、解析并校验这些资产:
|
||||
|
||||
```rust
|
||||
struct ModeAssets {
|
||||
system_prompt: Arc<str>,
|
||||
prompt: Arc<str>,
|
||||
runtime: Arc<str>,
|
||||
tools: Arc<[ToolDefinition]>,
|
||||
runtime_reminders: Arc<[RuntimeTemplate]>,
|
||||
}
|
||||
```
|
||||
|
||||
模式映射:
|
||||
模式映射和消费规则:
|
||||
|
||||
```text
|
||||
AGENT_MODE_AGENT → common prefix + agent prompt/tools
|
||||
AGENT_MODE_ASK → common prefix + ask prompt/tools
|
||||
AGENT_MODE_PLAN → common prefix + plan prompt/tools/reminder
|
||||
AGENT_MODE_DEBUG → debug prompt/tools + initial/continuing reminder
|
||||
AGENT_MODE_MULTITASK → common prefix + multitask prompt/tools
|
||||
子 Agent conversation → subagent prompt +受限 tools
|
||||
UserMessage.mode → 当前 Run 的 prompt.md + runtime.md + tools manifest
|
||||
conversation_state.mode → 仅给没有 UserMessage.mode 的后台完成等动作提供模式
|
||||
subagent_type_name → 明确选择 subagent 资产
|
||||
```
|
||||
|
||||
静态 prompt 和工具目录按 mode 选择;运行时 reminder 必须遵守第 23 节的 exactly-once append,不能因为每轮加载同一个模板而重复加入 messages。模式或工具集合切换可以形成新的 provider cache 边界,但已提交的模型 messages 仍然保持严格只追加。
|
||||
不能用恢复出来的 `conversation_state.mode` 覆盖当前 `UserMessage.mode`;否则 UI 刚切换 Ask/Plan/Debug/Multitask 时,本轮仍会用旧模式的 prompt 和 tools。也不使用目录别名或缺失资产 fallback:每个可用模式都必须显式维护自己的 `prompt.md` 和 `runtime.md`,缺失或模板占位符非法时服务启动失败。
|
||||
|
||||
`runtime.md` 是一次性渲染的 Markdown 模板。通用占位符为:
|
||||
|
||||
```text
|
||||
{{REQUEST_CONTEXT}}
|
||||
{{OPEN_FILES}}
|
||||
{{SELECTED_CONTEXT}}
|
||||
{{ACTION_CONTEXT}}
|
||||
{{TIMESTAMP}}
|
||||
{{USER_QUERY}}
|
||||
```
|
||||
|
||||
Debug 额外使用 `{{DEBUG_SERVER_ENDPOINT}}`、`{{DEBUG_LOG_PATH}}` 和 `{{DEBUG_SESSION_ID}}`。模板必须包含 `TIMESTAMP` 和 `USER_QUERY`;其他区块完全取决于当前 RunRequest:有数据就加入,没有就渲染为空,不从历史猜测,不制造空标签,不使用默认内容托底。渲染是单遍替换,用户文本中恰好出现 `{{...}}` 不会被当成第二层模板执行。
|
||||
|
||||
当前请求的 `RequestContextRulesPart`、`RequestContextSkillsPart`、`RequestContextSubagentsPart` 和 `RequestContextMcpsPart` 先按 BlobID 取回,校验 hash 和 byte length,再按明确 protobuf 类型解码;缺 Blob、长度不符或类型错误都是协议错误,不能忽略。公共请求上下文按抓包顺序编译为 `user_info → git_status → agent_transcripts → rules/skills/subagents/MCP`,后四类同样只在当前请求携带时出现。
|
||||
|
||||
每个携带用户语义的 RunRequest 最终只产生一条 `role=user, origin=runtime` 的 canonical message:模式 reminder、当前请求上下文、时间、`user_query` 和图片都在同一条 message 中。原始 `UserMessage.text` 不再另行投射,避免同一用户问题出现两次。该 message 以 `run-request:{request_id}` 作为 runtime event identity,在 Start 或 Resume 进入 provider 前与 messages 一起持久化;恢复和 provider 重试只能重放已持久化文本,不能重新取时间或重新渲染。
|
||||
|
||||
静态 prompt 和工具目录按 mode 选择;运行时 message 必须遵守第 23 节的 exactly-once append。模式或工具集合切换可以形成新的 provider cache 边界,但已提交的模型 messages 仍然保持严格只追加。
|
||||
|
||||
|
||||
|
||||
@@ -1355,7 +1450,7 @@ BidiAppend.run_request
|
||||
→ 投射 RunSSE 流事件
|
||||
→ 客户端执行 Tool
|
||||
→ BidiAppend 返回结果
|
||||
→ 单 Tool checkpoint
|
||||
→ ToolRound settled checkpoint
|
||||
→ 下一轮 LLM
|
||||
→ turn_ended
|
||||
→ final checkpoint
|
||||
@@ -1367,7 +1462,7 @@ Runtime event 恰好追加一个 runtime-origin/user-role message。
|
||||
Provider 重试只重放,不能重复追加任何 message。
|
||||
Tool call/result 必须一对一完整,不能向 LLM 投射悬空调用。
|
||||
Tool 可以乱序完成,但下一轮 LLM 必须等待整个 Tool Batch 完整。
|
||||
每个 Tool 单独持久化和 checkpoint,避免有副作用工具被重复执行。
|
||||
每个 Tool Result 单独原子持久化,但只在整个 ToolRound staged/settled 边界发布 checkpoint。
|
||||
Blob 先确认,checkpoint 后发布。
|
||||
turn_ended、final checkpoint、EndStream 是三个独立边界。
|
||||
Usage 只信任 provider,最终按整个 Turn 汇总。
|
||||
@@ -1484,29 +1579,28 @@ is_expected = false
|
||||
成功路径:
|
||||
|
||||
```text
|
||||
业务消息
|
||||
→ Blob SET / ACK barrier
|
||||
→ turn_ended + final checkpoint
|
||||
最终 assistant revision 提交
|
||||
→ staged/settled 所需 Blob SET / ACK barrier
|
||||
→ turn_ended
|
||||
→ staged pending=1
|
||||
→ settled pending=0
|
||||
→ 幂等重发同一 settled
|
||||
→ EndStream {}
|
||||
→ 关闭 RunSSE 输出
|
||||
```
|
||||
|
||||
真实抓包的客户端可见尾序列是 `turn_ended → 重复 final checkpoint → EndStream {}`。`main` 分支现有 Go 实现是 `Blob ACK → checkpoint → turn_ended → EndStream {}`;两者的最终语义相同,但 Rust 的协议兼容测试应固定所采用的客户端可见顺序。
|
||||
真实抓包稳定呈现 `turn_ended → staged → settled → settled 重发 → EndStream {}`。Cursor.app 将 `turn_ended` 之前的 checkpoint 视为 eligible,将其后的终局快照视为 `ineligible_terminal_turn`;这些顺序不能因“最终语义相同”而交换。
|
||||
|
||||
Provider 失败路径:
|
||||
|
||||
```text
|
||||
停止 provider
|
||||
→ 保存已经收到并确认的部分 assistant 输出
|
||||
→ 保存 provider 已汇报的 usage 与失败元数据
|
||||
→ 从当前已提交 messages 构造 checkpoint
|
||||
→ Blob SET / ACK barrier
|
||||
→ 发布 checkpoint
|
||||
→ Error EndStream
|
||||
→ 关闭 RunSSE 输出
|
||||
```
|
||||
|
||||
失败路径不发送 `turn_ended`,不发送错误 `TextDelta`,也不把错误字符串追加为 assistant message。已经作为正常 provider delta 发出的部分内容可以保留;错误本身只存在于 run 元数据和 Connect error 中。若在 checkpoint Blob 同步失败时采用超时策略,可以跳过未获确认的 checkpoint,但仍必须发送 Error EndStream,不能让流永久悬挂。
|
||||
失败路径不发送 `turn_ended`,不发送错误 `TextDelta`,不把错误字符串或半截 assistant 追加进 canonical messages,也不伪造新的成功 checkpoint;失败前已经发布的 initial/settled checkpoint 仍然有效。已经发送到 UI 的 partial text/thinking 只作为诊断展示;错误本身进入 Run 元数据和 Connect error。checkpoint Blob 构造或 ACK 失败同样直接进入 Error 生命周期。
|
||||
|
||||
用户取消或新 Run 打断旧 Run:
|
||||
|
||||
@@ -1518,7 +1612,7 @@ Provider 失败路径:
|
||||
→ 关闭旧 RunSSE 输出
|
||||
```
|
||||
|
||||
取消不发送 `turn_ended`,也不发布一个代表成功完成的新 checkpoint。Cursor 对 Connect `canceled` 有专门处理,不应将它显示为普通错误。已在更早的单 Tool checkpoint 中确认的副作用和消息保持有效;未完成工具不能投射进下一轮 LLM。
|
||||
取消不发送 `turn_ended`,也不发布一个代表成功完成的新 checkpoint。Cursor 对 Connect `canceled` 有专门处理,不应将它显示为普通错误。此前已经发布的 settled ToolRound checkpoint 保持有效;尚未 settled 的工具批次不能投射进下一轮 LLM。
|
||||
|
||||
### 26.5 统一终结不变量
|
||||
|
||||
@@ -1591,13 +1685,13 @@ RunRegistry
|
||||
|
||||
下发 Exec 前在内存中建立 `id → call_id`,客户端结果到达时用 `message.id` O(1) 查找,不查 SQLite。数字 ID 在整个 request 内单调递增,条目在 result/exit 到达后标记为 `ResultReceived`,在随后的 `stream_close` 到达时删除。Run 取消或失败时对仍为 `Running` 的 ID 发送 abort,然后清空全部条目。
|
||||
|
||||
这个映射是运行期协议状态,不是上下文事实源。SQLite 只保存已经关联成功的 `run_tool_results` 和 checkpoint,不参与每个 Shell 流片段的实时查找。
|
||||
这个映射是运行期协议状态,不是上下文事实源。SQLite 只在 durable `tool_round_calls` 中保存已经关联成功的 ToolResult,不参与每个 Shell 流片段的实时查找;checkpoint 也不是 SQLite 中的第二份会话状态。
|
||||
|
||||
客户端会在 result/exit 之后紧接着发送 `stream_close`。因此 `run_tool_results` 的持久化不能使用“事务内先 SELECT completion_seq,再将 deferred transaction 升级为写事务”的方式,它会和 Bidi `append_seqno` 的并发更新产生 `SQLITE_BUSY_SNAPSHOT`。completion_seq 的计算和 ToolResult 插入必须合并为单条原子 `INSERT ... SELECT`。
|
||||
客户端会在 result/exit 之后紧接着发送 `stream_close`。ToolResult 的 completion_seq、call 状态、assistant/result message pair、ToolRound version 和新 revision 必须在同一个 immediate transaction 中推进,不能先读序号再把 deferred transaction 升级为写事务,否则会留下竞态或 `SQLITE_BUSY_SNAPSHOT`。
|
||||
|
||||
### 26.8 ToolResult 向 LLM 的字符串投射
|
||||
|
||||
Canonical `ToolResult.output` 允许保存任意 JSON Value,因为 Todo/Plan fold、checkpoint 和调试都需要保留工具结果的结构。但是投射到 LLM 请求时,ToolResult content 必须始终是字符串,不能把 JSON object、array、number、boolean 或 null 直接放入 message content。这是所有 provider adapter 的共同输入不变量,不应由 OpenAI Chat、Responses 或 Anthropic 各自补救。
|
||||
Canonical `ToolResult.content` 本身就是字符串。adapter 在 typed Cursor 结果进入核心之前只做一次规范化:文本原样保存,结构化结果用确定性的 JSON 序列化保存。OpenAI Chat、Responses 和 Anthropic 因而都读取同一个 String,不在各端点重复猜测 JSON 类型。
|
||||
|
||||
已观测的失败是 `TodoWrite` 将对象结果持久化后,projector 直接生成:
|
||||
|
||||
@@ -1620,12 +1714,13 @@ OpenAI Chat 因此拒绝 `messages[7]`,报错 `content should be a string or a
|
||||
统一规则:
|
||||
|
||||
```text
|
||||
output 是 JSON string → 直接使用原字符串,不二次加引号
|
||||
output 是其他 JSON 类型 → serde_json::to_string(output)
|
||||
最终 ProviderMessage.content → 始终 Value::String
|
||||
typed terminal result
|
||||
→ Cursor adapter 生成 String ToolResult.content
|
||||
→ SQLite/canonical message 原样保存该 String
|
||||
→ Provider adapter 按本端点的 tool-result 字段放入同一个 String
|
||||
```
|
||||
|
||||
字符串化只发生在 `CanonicalMessage → ProviderMessage` 边界;SQLite、Blob 和 derived state 仍保留原始结构化 JSON。这样既满足 LLM 端点约束,又不破坏幂等状态投影。
|
||||
Todo/Plan/UpdateCurrentStep 等派生状态需要结构时,从已知工具的字符串 content 严格解析自己的 JSON schema;解析失败是协议错误或表示该工具没有可派生状态,不能把 canonical 类型重新放宽成任意 Value。这样 core 与 Provider 都不需要知道 Cursor protobuf。
|
||||
|
||||
### 26.9 Thinking 历史的端点投射
|
||||
|
||||
@@ -1640,12 +1735,13 @@ The `reasoning_content` in the thinking mode must be passed back to the API.
|
||||
公共投射必须保持中性结构:
|
||||
|
||||
```text
|
||||
ProviderMessage
|
||||
├─ content = assistant text
|
||||
└─ thinking = assistant thinking
|
||||
ProjectedMessage::Assistant
|
||||
├─ text = 可展示 assistant text
|
||||
├─ thinking = 可展示 thinking summary
|
||||
└─ replay_state = 端点产生的不透明续传状态
|
||||
```
|
||||
|
||||
具体 provider adapter 再负责端点字段映射。OpenAI Chat 必须生成:
|
||||
具体 provider adapter 再负责端点字段映射。OpenAI Chat 只有在 replay state 的 `provider_kind=openai_chat` 且其中确实包含 `reasoning_content` 时才生成:
|
||||
|
||||
```json
|
||||
{
|
||||
@@ -1656,10 +1752,9 @@ ProviderMessage
|
||||
}
|
||||
```
|
||||
|
||||
DeepSeek 官方文档进一步明确了这里不是“字段存在即可”的校验:
|
||||
DeepSeek 的 Chat 兼容端点进一步明确了这里不是“字段存在即可”的校验:
|
||||
|
||||
- 未调用工具的 assistant thinking,在后续请求中可以不回传;即使回传也会被忽略。
|
||||
- 只要 assistant 调用了工具,该次模型响应的 `reasoning_content` 就必须完整、原样参与后续请求。
|
||||
- 只要 assistant 调用了工具,该次模型响应的 `reasoning_content` 就必须完整参与后续请求。
|
||||
- 官方示例直接追加完整的 `response.choices[0].message`,即同一条 assistant message 同时包含 `content`、`reasoning_content` 和该次响应的全部 `tool_calls`。
|
||||
- 用 `reasoning_content: ""` 给拆分出的 assistant tool-call message 补字段不是正确修复;它仍然丢失了原始思维内容。
|
||||
|
||||
@@ -1670,7 +1765,7 @@ DeepSeek 官方文档进一步明确了这里不是“字段存在即可”的
|
||||
```text
|
||||
Cursor 持久化与 checkpoint 视图
|
||||
assistant(call 1) → tool result 1 → assistant(call 2) → tool result 2
|
||||
单工具完成、单工具 checkpoint
|
||||
单结果原子提交;ToolRound 完整后 settled checkpoint
|
||||
|
||||
LLM provider 请求视图
|
||||
assistant(
|
||||
@@ -1685,11 +1780,12 @@ assistant(
|
||||
服务端继续按已完成工具保存 1:1 pair,因此中断时不会把尚未得到结果的 tool call 投射给下一次 LLM。每条 canonical assistant tool message 额外保存:
|
||||
|
||||
```text
|
||||
model_call_id 同一次 provider 响应的分组键
|
||||
model_call_id 同一次 provider 调用的 UI/观测关联值
|
||||
tool.index provider 返回的原始 tool-call 顺序
|
||||
tool_round_id durable assistant/result 分组身份
|
||||
```
|
||||
|
||||
公共 projector 在编译模型请求时,按 `model_call_id` 合并同一响应的 assistant pair,取唯一的非空 `text` 和完整 `thinking`,按 `tool.index` 恢复全部 tool calls,再按同一顺序投射 ToolResult。这样 SQLite/Blob 与 Cursor 仍保留单工具粒度,而 OpenAI Chat 端点看到的是其要求的原始 assistant 响应形状。
|
||||
公共 projector 以 durable `tool_round_id` 合并同一响应的 assistant pair,取唯一的非空 `text`、完整 `thinking` 和 provider replay state,按 `tool.index` 恢复全部 tool calls,再按真实 `completion_seq` 投射 ToolResult。`model_call_id` 只用于 UI/调用关联,不承担持久分组身份。这样 SQLite 保留真实完成顺序,而 OpenAI Chat 端点看到的是其要求的原始 assistant 响应形状。
|
||||
|
||||
关键不变量:
|
||||
|
||||
@@ -1701,9 +1797,9 @@ ToolBatch 未完整时不发起下一轮 LLM 请求
|
||||
完成后的 provider messages 中不存在悬空 tool_calls
|
||||
```
|
||||
|
||||
普通模型从未返回 thinking 时,请求形状不变。OpenAI Responses 和 Anthropic 不能直接复用 `reasoning_content` 字段,应由各自 adapter 按端点原生结构处理;公共层不将 thinking 降级为普通文本。
|
||||
普通模型从未返回 replay state 时,请求形状不变。OpenAI Responses 要回传完整 reasoning output items/encrypted content,Anthropic 要回传完整 thinking blocks/signatures;两者都不能复用 `reasoning_content` 字段。公共层不将可展示 thinking 冒充任一端点的续传状态,跨 Provider 时也不解码其他端点的 capsule。
|
||||
|
||||
旧版本已写入的 assistant tool pair 没有 `model_call_id` 和 `tool.index`,无法无歧义恢复原始 provider 响应。服务端不猜测旧分组;验证本修复应新建对话。新数据不需要额外迁移。
|
||||
当前实现不兼容旧 schema 或猜测缺失分组;初始 schema 直接保存 ToolRoundId、call index、completion sequence 和 replay state。
|
||||
|
||||
### 26.10 Tool 完成事件与 UI 生命周期
|
||||
|
||||
@@ -1736,7 +1832,7 @@ ToolCallCompletedUpdate
|
||||
|
||||
- `ExecClientMessage.message = None` 不是完成结果,只表示尚无载荷;必须保持 Pending,随后等待 typed result、Shell exit、throw 或异常 stream close。
|
||||
- `PendingExecRegistry` 在下发 Exec 时保存完整 `ToolCall`、真实 `started_at_ms` 和 Shell 流缓冲;数字 ID 只在当前 Run 内用于关联。
|
||||
- Shell、Delete、Grep、Ls、ReadMcpResource、WriteShellStdin 等可直接复用上行 typed result;Read、Write、Diagnostics、MCP、Subagent、PiEdit 按 Cursor ToolCall 所需结果类型做无损或语义等价转换。
|
||||
- Shell、Delete、Grep、ReadMcpResource 等可直接复用上行 typed result;Read、Write、Diagnostics、MCP、Subagent 与编辑工具按 Cursor ToolCall 所需结果类型做无损或语义等价转换。
|
||||
- `TodoWrite` 这类服务端本地工具必须构造明确 typed success。`ExecClientThrow` 不是工具结果,直接进入统一 Error 生命周期,不伪造成某个 typed result。
|
||||
- Canonical `ToolResult` 继续用于 LLM 和持久化;不能用它替代 UI 所需的 typed protobuf result。
|
||||
- 成败不能通过完整 protobuf `Debug` 字符串搜索 `Error`、`Failure` 等单词判断。成功写入的文件内容可能恰好包含这些词,从而产生假失败。已知 oneof 必须按具体 success/error variant 判定。
|
||||
@@ -1750,50 +1846,50 @@ ToolCallStarted(args, started_at_ms)
|
||||
→ ExecClientMessage(id, typed result) / Shell exit
|
||||
├→ take(id) 消费 PendingExec 的唯一所有权
|
||||
└→ 同时生成 Canonical ToolResult 与完整 typed ToolCall
|
||||
→ Blob 存储确认与单 Tool checkpoint
|
||||
→ ToolCallCompleted(args + typed result + timestamps)
|
||||
→ ToolBatch 全部完整后进入下一轮 LLM
|
||||
→ 最后一个结果提交后构造 ToolRound settled Blob/Turn
|
||||
→ Blob ACK + settled checkpoint
|
||||
→ 下一轮 LLM
|
||||
```
|
||||
|
||||
客户端通常在 typed result 后立即发送 `stream_close`。终态 result 已经消费 PendingExec,因此 close 只是幂等尾包;不能再维护额外的 Finished/Closed 状态。`ToolCallCompleted` 必须在该工具的消息与 checkpoint 已经提交后发布。
|
||||
客户端通常在 typed result 后立即发送 `stream_close`。终态 result 已经消费 PendingExec,因此 close 只是幂等尾包;不能再维护额外的 Finished/Closed 状态。`ToolCallCompleted` 与 staged checkpoint 没有伪全局顺序;硬约束是 typed result 对应的 canonical 消息已经提交,且整个 ToolRound 的 settled checkpoint 先于下一轮模型 interaction。
|
||||
|
||||
### 26.11 Tool completion 的模块边界
|
||||
|
||||
首次实现虽然修正了协议,但将 pending registry、结果通道和 typed protobuf 转换同时塞进了 `run/tool_batch.rs`、`cursor/exec.rs` 与 `cursor/interaction.rs`,使运行期关联、Exec 解析、UI 投射互相穿插。整理后的职责为:
|
||||
整理后的职责为:
|
||||
|
||||
```text
|
||||
cursor/pending.rs
|
||||
└─ id → ToolCall/timing/stream buffer;存在即 Running,take 即终态
|
||||
cursor/tools/runtime.rs
|
||||
└─ 当前 Cursor Run 的 wire_id → PendingExec/PendingInteraction 与完成墓碑
|
||||
|
||||
cursor/tools.rs
|
||||
└─ 工具批次的 Cursor step index、唯一 transport 和本地立即完成工具
|
||||
cursor/tools/dispatch/
|
||||
└─ 完整 ToolCall → 唯一 Exec/Interaction/Local transport
|
||||
|
||||
cursor/exec.rs
|
||||
└─ 解析 ExecClientMessage/ShellStream,产生 ToolCompletion
|
||||
cursor/tools/codec/{request,response}.rs
|
||||
└─ ExecServerMessage 编码与 ExecClientMessage/ShellStream 解码
|
||||
|
||||
cursor/tool_result.rs
|
||||
├─ ToolCompletion 与结果 channel
|
||||
├─ typed Exec/Interaction result → canonical 字符串结果 + typed ToolCall
|
||||
└─ 本地 TodoWrite/CommunicateUpdate 的明确终态
|
||||
cursor/tools/result/
|
||||
└─ typed terminal result → String ToolResult + typed ToolCall
|
||||
|
||||
cursor/interaction.rs
|
||||
└─ text/thinking/tool started/completed/usage 的事件外壳与 args 渲染
|
||||
cursor/interaction/
|
||||
└─ 模型流、InteractionQuery 和 typed ToolCall UI 渲染
|
||||
|
||||
run/loop_engine.rs
|
||||
└─ 只决定何时持久化、checkpoint、发布 completion 和进入下一轮
|
||||
run/tool_round.rs
|
||||
└─ 只提交 canonical call/result、等待整批完成与 client state barrier
|
||||
|
||||
cursor/checkpoint/worker.rs
|
||||
└─ 串行构造 staged/settled/final Blob 图并发布 checkpoint
|
||||
```
|
||||
|
||||
原 `run/tool_batch.rs` 和 `model::ToolBatch` 均已删除。Loop 已经持有有序 `ordered_calls`,只需一个 `completed call_id` 集合判断 barrier;再复制一份 calls/results 容器没有增加信息。Cursor 数字 ID、protobuf typed result 和 UI 卡片状态也不再伪装成 Loop 领域状态。
|
||||
|
||||
`ToolCompletion` 对 Loop 是一个需要延迟发布的不透明完成信封:Loop 读取其中的 canonical `ToolResult` 完成消息和 checkpoint,Cursor adapter 读取 presentation payload 生成 UI typed result。Loop 不匹配 protobuf oneof,也不决定某种工具在 Cursor UI 中如何展示。
|
||||
`ToolCompletion` 不越过 client boundary。CursorSession 读取其中的 String ToolResult 发送通用 `ClientCommand::ToolResult`,同时保留 typed presentation,等核心回送对应 `StateCommitted` 后发布 UI completion。Loop 从未持有 protobuf oneof,也不决定某种工具在 Cursor UI 中如何展示。
|
||||
|
||||
### 26.12 自然状态与无 fallback 约束
|
||||
|
||||
本轮整理删除了几类会掩盖协议错误的级联和猜测:
|
||||
|
||||
- 工具不会再依次尝试 `Exec → Interaction → Local`。名称在 `cursor/tools.rs` 映射到唯一 transport,Loop 不持有 `ToolRoute`;未知工具立即报 Protocol error。动态 MCP 只有在本轮定义表中存在时才走 Exec。
|
||||
- 工具不会再依次尝试 `Exec → Interaction → Local`。名称在 `cursor/tools/dispatch/` 映射到唯一 transport,Loop 不持有 `ToolRoute`;未知工具立即报 Protocol error。动态 MCP 只有在本轮定义表中存在时才走 Exec。
|
||||
- Pending 项不存在 `Running/Finished/Closed` 并行标志。存在于 map 就是 Running;terminal result、throw、提前 close 都通过 `take(id)` 消费唯一所有权。
|
||||
- `ToolCompletion` 不允许只有 canonical result、没有 UI presentation 的半成品。构造成功即同时拥有可稳定投射给 LLM 的结果与完整 typed ToolCall;结构化本地结果只在 provider 边界字符串化。
|
||||
- `ToolCompletion` 不允许只有 canonical result、没有 UI presentation 的半成品。构造成功即同时拥有 String ToolResult 与完整 typed ToolCall;结构化本地结果在 Cursor result adapter 边界只字符串化一次。
|
||||
- `ToolCall.name` 与 `ExecClientMessage` oneof 必须精确匹配。Read 对上 WriteResult 等组合直接报 Protocol error,且已经消费该 terminal ID,不能继续复用。
|
||||
- 不再通过 protobuf `Debug` 文本生成 tool result 或判断成败;每个支持的 oneof 都显式读取。
|
||||
- LLM 返回的工具参数必须是合法 JSON;不再在解析失败时降级成普通字符串。
|
||||
@@ -1806,7 +1902,8 @@ run/loop_engine.rs
|
||||
```text
|
||||
tool name → 唯一 transport
|
||||
pending id → take → ToolCompletion
|
||||
ToolCompletion → persist/checkpoint → publish
|
||||
ToolCompletion → ClientCommand → durable commit → UI publish
|
||||
ToolRound settled → checkpoint barrier → next model call
|
||||
```
|
||||
|
||||
默认值只保留在协议本身定义为 optional 的字段上;它不能用来掩盖缺少必需字段、未知消息类型或不匹配的生命周期。
|
||||
@@ -1839,7 +1936,7 @@ Cursor tool dispatcher
|
||||
- Task 返回 `agent_id` 与 `background_reason` 后,父 Tool 即完成;子代理随后通过独立 conversation/RunSSE 继续。父 Loop 不等待子 Run 结束。
|
||||
- `request_context` 和 `execute_hook` 虽然也使用 ExecServer/ClientMessage,但不是 LLM Tool,不能追加 assistant/tool pair。
|
||||
|
||||
`AwaitShell` 不是该协议中的工具。proto 中的 `AwaitToolCall / SubagentAwaitArgs` 属于 Task/Subagent 语义,不能因为字段形状相近就把 `shell_id` 填入 `agent_id`。服务端不暴露 `AwaitShell`,也不保留该错误映射;后台 Shell 只使用实际存在的 Shell、ForceBackgroundShell 与 WriteShellStdin 消息。
|
||||
`AwaitShell` 是抓包确认的模型工具,Cursor pending contract 的 identifier 为 `AWAIT`,typed UI 使用 `AwaitToolCall`。它通过终端输出文件、等待时间和可选正则表达多阶段等待;不能把 `shell_id` 错填进 Subagent 的 `agent_id`。`ForceBackgroundShell` 与 `WriteShellStdin` 不是当前模型工具,不出现在 tool catalog,也不作为 Shell 的 fallback;后台化只由 Shell stream 的真实 `Backgrounded` 结果表达。
|
||||
|
||||
`CommunicateUpdateSuccess.message_index` 是当前 Turn 中该 ToolCall 对应的 `ConversationStep` 一基位置,不是本地调用次数。抓包第一组事件依次产生 thinking、assistant text、CommunicateUpdate,因此结果为 `message_index = 3`;同一 Turn 后续样本累计为 `6`。服务端按已提交步骤数、当前 thinking/text 和本批 call 位置确定该值。
|
||||
|
||||
@@ -1859,25 +1956,24 @@ WebFetch 的第二阶段可以由 proto 无歧义确定:approval 后将同一
|
||||
Cursor adapter 识别 typed terminal
|
||||
→ ToolCompletion(canonical result + typed UI result)
|
||||
→ Loop 统一持久化
|
||||
→ Blob ACK barrier
|
||||
→ 单 Tool checkpoint
|
||||
→ ToolCallCompleted
|
||||
→ 整批完整后下一轮 LLM
|
||||
→ 整批完整后 Blob ACK + settled checkpoint barrier
|
||||
→ 下一轮 LLM
|
||||
```
|
||||
|
||||
具体落点:
|
||||
|
||||
- `cursor/tools.rs` 独占工具路由和本地工具启动,并计算 Cursor step index。
|
||||
- `cursor/exec.rs` 独占 Shell 流阶段与 Exec wire event。
|
||||
- `cursor/tool_result.rs` 独占 typed result 到 `ToolCompletion` 的转换。
|
||||
- `run/loop_engine.rs` 不再包含 `ToolRoute` 或工具名称表。
|
||||
- `cursor/tools/dispatch/` 独占工具路由和本地工具启动。
|
||||
- `cursor/tools/codec/response.rs` 独占 Shell 流阶段与 Exec wire event 解码。
|
||||
- `cursor/tools/result/` 独占 typed result 到 `ToolCompletion` 的转换。
|
||||
- `run/engine.rs` 和 `run/tool_round.rs` 不包含 `ToolRoute` 或工具名称表。
|
||||
- 未知工具与不匹配 oneof 立即返回 Protocol Error;不尝试 Exec → Interaction → Local fallback。
|
||||
|
||||
## 27. Run 进度观测:provider_call_index 必须随 Loop 更新
|
||||
|
||||
对运行中的 `4468e12f-4f90-4bd9-90ed-d57c9c2bc7a9` 复核后,最初看到的状态并不是卡在第一个 Ls:Ls 的 typed result、messages 和 checkpoint 都已经完成,Run 随后继续完成 Write、ReadLints、Shell 等调用,最终形成 8 轮 provider call 并正常结束。此前数据库始终显示 `provider_call_index = 0`,只是该字段从未被 Loop 更新,因而给出了错误的观测结果。
|
||||
对运行中的 `4468e12f-4f90-4bd9-90ed-d57c9c2bc7a9` 复核后,UI loading 不能仅凭数据库某一列判断 Loop 是否越过工具 barrier。该 Run 随后继续完成编辑、ReadLints、Shell 等调用,最终形成 8 轮 provider call 并正常结束。此前数据库始终显示 `provider_call_index = 0`,只是该字段从未被 Loop 更新,因而给出了错误的观测结果。
|
||||
|
||||
`append_seqno` 只表示 BidiAppend 上行序号推进,不能回答当前正在执行第几轮 LLM。`run_tool_results` 在批次完成后会被清除,outbox ACK 也只能说明 checkpoint 已确认;它们都不能替代 provider 调用进度。
|
||||
`append_seqno` 只表示 BidiAppend 上行序号推进,不能回答当前正在执行第几轮 LLM。ToolRound 状态和 Blob SET ACK 也不能替代 provider 调用进度;协议不存在 checkpoint ACK 或持久 outbox。
|
||||
|
||||
固定规则为:
|
||||
|
||||
@@ -1981,3 +2077,278 @@ ShellStream Backgrounded(shell_id + pid)
|
||||
- Backgrounded 终态生成成功的 canonical 字符串结果,其中包含 shell_id、pid、terminals_folder 和后台化前已收到的输出;typed ShellResult 同时保留这些字段供 Cursor UI 使用。
|
||||
- Runtime environment prompt 明确追加 terminals folder,使下一轮 LLM 可以按 Shell 工具规则读取后台日志。
|
||||
- Backgrounded 已是当前 ToolCall 的终态。后台输出不重新打开 ToolCall,也不引入不存在的 AwaitShell。
|
||||
- `Backgrounded` 只结束当前 ToolCall,不结束客户端持有的后台进程;成功 `TurnEnded/EndStream` 不发送 `ExecServerAbort`。失败或取消也只 abort 尚未返回终态的 Exec,不能回收已经后台化的 Shell。
|
||||
- 后台化只有一层:长驻命令本身保持前台形式,例如 `python3 -m http.server 9000`,并以 `block_until_ms=0` 交给 Cursor 管理。不能同时使用 `nohup`、`&` 或 `disown`;否则 Cursor 管理的是很快退出的外层 shell,真实子进程不再具有后台 Shell 生命周期。
|
||||
|
||||
本地异常样本 `run_id=2aacf882-3b6b-4b66-9c08-5342ee5cd6b6` 正是双重后台化:Shell 参数已经是 `block_until_ms=0`,command 又执行 `nohup python3 -m http.server 9000 ... &`。Run 正常 completed,服务端没有 abort;终端 `121702` 记录外层命令成功结束,而真正 server 子进程随后消失。因此修复位于 Shell 模型契约,codec 保持官方 ShellArgs,不对用户命令做字符串改写。
|
||||
|
||||
## 30. Write / StrReplace 的参数流、展示流与执行边界
|
||||
|
||||
provider 的 `ToolCallArgumentsDelta` 是原始 JSON 文本增量,Cursor 的 `EditToolCallDelta.stream_content_delta` 是编辑卡片消费的语义内容增量。两者不是同一种事件。
|
||||
|
||||
最新官方抓包中的两个编辑分别写入 10414 字符和修改 6 字符。两者在 Cursor UI 层都表现为 `EditToolCall`,Exec 层都实际执行 `ReadArgs → ReadResult → WriteArgs → WriteResult`,没有出现 `PiEditArgs`。第一次使用数字 id 46/47,第二次使用缺省 id 0/1;每组 Read 和 Write 都复用同一个原始 `tool_call_id`。
|
||||
|
||||
固定事件映射为:
|
||||
|
||||
```text
|
||||
LLM ToolCallStart
|
||||
→ PartialToolCallUpdate(call_id/name, 空 Edit 占位)
|
||||
|
||||
LLM ToolCallArgumentsDelta(raw JSON)
|
||||
→ 增量 JSON 字符串解码
|
||||
→ Write.contents / StrReplace.new_string 的已解码字符
|
||||
立即发布 ToolCallDelta(EditToolCallDelta.stream_content_delta)
|
||||
→ path 完整后发布 PartialToolCall(EditArgs.path)
|
||||
|
||||
LLM 参数完整
|
||||
→ 对完整 arguments_text 做一次严格 JSON 解析
|
||||
→ ToolCallStarted(EditArgs.path + 已累计的完整 stream_content)
|
||||
→ 隐藏 ReadArgs(path, 同一个 tool_call_id)
|
||||
|
||||
BidiAppend ReadResult
|
||||
→ Write:得到 before;file_not_found 表示 before 为空
|
||||
→ StrReplace:在 before 上执行规范化后的精确 old_string/new_string 替换
|
||||
→ 隐藏 WriteArgs(path, 完整 after, 同一个 tool_call_id)
|
||||
|
||||
BidiAppend WriteResult
|
||||
→ 用 before/after 构造 diff、lines_added、lines_removed 和 EditResult
|
||||
→ 持久化完整 assistant/tool pair
|
||||
→ ToolCallCompleted
|
||||
→ 若为本轮最后结果:Blob ACK 与 ToolRound settled checkpoint
|
||||
```
|
||||
|
||||
`EditToolCallDelta` 不依赖 path,也不依赖 `ToolCallStarted`。官方抓包明确出现“完整内容 delta → path partial → started”的顺序,因此服务端不能把内容缓存到 path 到达之后。`ToolCallStarted` 是参数已经完整、即将执行的边界,不是编辑增量的前置条件。
|
||||
|
||||
Read 和 Write 是两个独立 Exec 请求,各有自己的数字 `id`,由 `PendingExecRegistry` 分别匹配 BidiAppend 返回;它们共享同一个 `tool_call_id`,因为对 UI 和 LLM 来说仍是同一个工具。客户端只执行普通 Read/Write,不知道服务端内部的两阶段状态。
|
||||
|
||||
编辑域的文本统一使用 LF:JSON 的 `\\n` 先解码为真实换行,再将 CRLF 和单独 CR 规范化为 LF。Read 内容、Write 完整内容、StrReplace 的 old/new、UI stream delta、精确匹配、diff 和 `WriteArgs.file_text` 使用同一规范文本。流式规范化必须保留 chunk 末尾未决的 CR,等下一 chunk 判断它是否与 LF 组成 CRLF,不能重复发布换行。
|
||||
|
||||
抓包中的 `tool_call_id` 含真实内部换行,例如 `call-...\nfc_..._0`。它是 Cursor wire 的不透明标识,不得拆分、重建或清理内部换行;Partial、Delta、Started、隐藏 Read、隐藏 Write、Completed 必须逐字复用。Provider 的 call id/item id 应作为独立元数据保存,不能靠反向解析这个组合值恢复。
|
||||
|
||||
实现保持 Loop 工具无关:`run/model_cycle.rs` 只消费统一 provider 事件,`cursor/tools/stream.rs` 只做实时 UI 投射,`cursor/tools/edit.rs` 负责 LF 规范化、替换计算和 diff,`cursor/tools/codec/response.rs` 负责隐藏 Read/Write 状态推进,`cursor/tools/runtime.rs` 保存当前阶段。没有 post-read、Windows path 猜测、内容不一致自动修复或旧编辑消息兼容路径。
|
||||
|
||||
messages、Blob 和 checkpoint 只保存最终完整 ToolCall 与 ToolResult。`PartialToolCall` 和 `EditToolCallDelta` 都是可丢弃的实时 UI 投影,不进入上下文事实源,也不影响下一轮 LLM 的前缀稳定性。
|
||||
|
||||
## 31. 本地 Agent 路由与 Cursor backend 转发边界
|
||||
|
||||
`--test-backend-url` 或等价 endpoint 配置会把大量 Cursor backend 请求送入本地服务,不只有 Agent loop。官方抓包中的这些请求具有统一上游 `https://api2.cursor.sh`。因此 Rust 服务不能把尚未实现的接口当作本地 404;否则模型列表、服务配置、对话 metadata、认证及其他旁路业务都会被误判为不存在。
|
||||
|
||||
固定路由顺序为:
|
||||
|
||||
```text
|
||||
incoming request
|
||||
├─ POST /agent.v1.AgentService/RunSSE
|
||||
│ └─ 本地 RunSSE handler
|
||||
├─ POST /aiserver.v1.BidiService/BidiAppend
|
||||
│ └─ 本地 BidiAppend handler
|
||||
└─ 其他 method/path
|
||||
└─ https://api2.cursor.sh + 原 path/query
|
||||
```
|
||||
|
||||
转发保持 method、path/query、端到端 headers 和 body;响应保持上游 status、端到端 headers 和 body。请求和响应都使用流,不先聚合完整正文,因此 Connect/SSE 和大请求不会被代理层阻塞。目标 `Host`/authority 必须改为上游,`Connection`、`Transfer-Encoding`、`Upgrade` 等 hop-by-hop headers 不能跨连接复制。
|
||||
|
||||
本地 `RequestDecompressionLayer` 只作用于两个被接管的 protobuf 路由。代理请求不经过本地解压,避免 body 已改变而 `Content-Encoding` 仍沿用原值。只有无法建立上游连接时才由本地返回 `502 unavailable`;上游实际返回的 4xx/5xx 不改写。
|
||||
|
||||
每次代理在收到上游响应头后记录 method、path、status 和耗时;连接失败记录 error。由此客户端出现 404 时可以明确区分:它是上游真实 404,而不是 Rust Router 漏注册产生的默认 404。
|
||||
|
||||
## 32. 子代理的写入、MCP 能力与工具集合
|
||||
|
||||
主对话 `conversation_id = c7e5502c-8953-4a73-b5bb-226dd9c0b8f3` 中,`request_id = 37fca97d-4f8a-487e-a465-bf6975654ffb` 的用户指令为:
|
||||
|
||||
```text
|
||||
接下来发起三个子代理,测试他们的文件写入和mcp能力
|
||||
其中2个是后台的,一个是前台的
|
||||
```
|
||||
|
||||
该轮实际创建了三个独立子对话:两个 `run_in_background = true`,一个 `run_in_background = false`。子代理能够执行文件写入和 MCP 操作,因此子代理不是只读搜索器,也不是只能返回文本的缩减 Loop。
|
||||
|
||||
抓包 checkpoint 中的 `pendingToolExecutionContracts.allowedToolNames` 确认,子代理当前工具集合为:
|
||||
|
||||
```text
|
||||
Shell
|
||||
Grep
|
||||
Delete
|
||||
WebSearch
|
||||
WebFetch
|
||||
GenerateImage
|
||||
ReadLints
|
||||
EditNotebook
|
||||
TodoWrite
|
||||
StrReplace
|
||||
Write
|
||||
Read
|
||||
Glob
|
||||
Task
|
||||
AwaitShell
|
||||
GetMcpTools
|
||||
FetchMcpResource
|
||||
SwitchMode
|
||||
UpdateCurrentStep
|
||||
CallMcpTool
|
||||
```
|
||||
|
||||
关键结论:
|
||||
|
||||
- 子代理明确包含 `Write`、`StrReplace`、`EditNotebook` 和 `Delete`,具备写文件及修改工作区的能力。
|
||||
- 子代理明确包含 `GetMcpTools`、`CallMcpTool` 和 `FetchMcpResource`,具备 MCP 发现、调用和资源读取能力。
|
||||
- `run_in_background` 只决定父 Run 是否等待子代理完成,不改变子代理的 tools、messages、Blob/checkpoint 或 LLM Loop 语义。前台和后台子代理都是完整的独立 Run。
|
||||
- 子代理工具集不含 `AskQuestion`,而是用 `UpdateCurrentStep` 向父 Task 的时间线报告进度和最终摘要。
|
||||
- `Task` 仍在子代理工具集中;是否允许再创建子代理由子 Run 末尾的 runtime/system reminder 和服务端策略约束,不应靠删除 wire tool 来猜测。
|
||||
|
||||
因此,服务端不应为“前台子代理”、“后台子代理”或“MCP 子代理”建立不同 Loop。它们共享同一个 `RunActor + ToolDispatcher`;差异只来自子 `RunRequest` 的代理类型、模型配置、runtime reminder 和父子关系字段。
|
||||
|
||||
## 33. Agent 工具资产与子代理自然派生
|
||||
|
||||
工具资产现在只有一个完整 schema 事实源:`prompt/cursor/tools.json`。不存在 `tools-full.json`,也不存在 Agent/Subagent 各自复制的完整 schema。`prompt/cursor/modes/*.json` 只按抓包保存有序名称;需要不同参数形状的 `Task.subagent` 是同一 catalog 中的显式 variant,`UpdateCurrentStep` 也在 catalog 中定义一次。
|
||||
|
||||
模型请求编译时按抓包关系形成最终工具集:
|
||||
|
||||
```text
|
||||
主 Agent = tools.json catalog
|
||||
× modes/agent.json 的有序选择
|
||||
|
||||
子 Agent = tools.json catalog
|
||||
× modes/subagent.json 的有序选择
|
||||
- AskQuestion
|
||||
+ Task.subagent(无 environment/cloud_base_branch)
|
||||
+ UpdateCurrentStep
|
||||
```
|
||||
|
||||
`suppress_subagent_progress_update_tool = true` 时再移除 `UpdateCurrentStep`。这不是 fallback 或兼容分支,而是 RunRequest 中有明确 wire 字段控制的能力。子代理仍保留 `Task`,但没有 Cloud 参数;`PatchEdit` 不再存在,统一使用当前协议中的 `StrReplace`。
|
||||
|
||||
抓包中主代理和子代理的基础 system prompt 使用相同 Blob hash。子代理身份、父任务和运行期要求由追加的 user/runtime 信息表达,因此子代理编译也使用 Agent prompt,不使用另一份容易漂移的缩减 system prompt。这同时保持 messages 的只追加语义和前缀稳定性。
|
||||
|
||||
### 33.1 Task 与子代理模型
|
||||
|
||||
`Task` 的自然链路为:
|
||||
|
||||
```text
|
||||
LLM Task arguments
|
||||
→ TaskToolCall.args(UI)
|
||||
→ ExecServerMessage.subagent_args(客户端执行)
|
||||
→ 独立子 RunRequest
|
||||
```
|
||||
|
||||
`generalPurpose` 在 `TaskArgs.subagent_type` 中编码为 `unspecified`,但在 `SubagentArgs.subagent_type` 中发送字符串 `generalPurpose`;`cursor-guide` 使用明确的 `cursor_guide` oneof,其余具有协议 oneof 的类型同理,自定义类型保留原始名称,不能先转小写再回写。
|
||||
|
||||
`SubagentArgs.parent_conversation_id` 使用当前 conversation;`root_parent_conversation_id` 使用 `conversation_group_id`,根对话没有 group 时才等于当前 conversation。`accept_hook_additional_contexts = false`,与抓包一致。模型在父 Run 内一次解析:`subagent_model_overrides` 的显式 model 优先,inherit 解析为父模型,disabled 直接拒绝该类型;没有 override 时,`Task.model = inherit` 或缺省同样解析为父模型,显式 model 则原样使用。确定的 `model_id` 才进入 SubagentArgs,子 RunRequest 再通过 `requested_model` 把模型和参数传给独立 Run。父子模型不同不改变 messages 或前缀缓存规则。
|
||||
|
||||
### 33.2 UpdateCurrentStep 与 checkpoint
|
||||
|
||||
模型工具名是 `UpdateCurrentStep`,Cursor protobuf 的表现类型仍叫 `CommunicateUpdateToolCall`。服务端必须保持这两个命名层次,不能向模型暴露旧名 `CommunicateUpdate`。
|
||||
|
||||
该工具本地立即完成,成功结果写入 canonical messages:
|
||||
|
||||
```text
|
||||
arguments.current_step / final_summary / completed_subtitle
|
||||
→ CommunicateUpdateToolCall
|
||||
→ success(current_step, message_index)
|
||||
→ canonical ToolResult
|
||||
```
|
||||
|
||||
子 BidiAppend 的 `X-Parent-Agent-Tool-Call-Id` 被绑定到 `RunHandle`,同一 Run 若收到冲突值直接报协议错误。checkpoint 不维护第二套可变进度状态,而是从已持久化的 assistant ToolCall 和对应 ToolResult fold 出 `CommunicateUpdateTurnState`,写入:
|
||||
|
||||
```text
|
||||
communicate_update_states_by_parent_tool_call_id[parent Task call_id]
|
||||
```
|
||||
|
||||
其中 `history[]` 保存每次 `current_step + message_index`,最后一次带值的调用提供 `final_summary` 和 `completed_subtitle`。因此恢复、重放和 checkpoint 都由 messages 唯一决定。
|
||||
|
||||
### 33.3 GetMcpTools 使用客户端实时状态
|
||||
|
||||
旧实现直接读取初始 RunRequest 的 MCP descriptor 快照并在服务端本地完成,这是错误的:它绕过了客户端当前连接状态。抓包确认的链路为:
|
||||
|
||||
```text
|
||||
GetMcpTools started
|
||||
→ ExecServerMessage.mcp_state_exec_args
|
||||
→ BidiAppend McpStateExecResult(success.servers / error / rejected)
|
||||
→ 按 server、toolName、pattern 过滤
|
||||
→ GetMcpTools completed
|
||||
```
|
||||
|
||||
现在 `GetMcpTools` 与其他客户端 Exec 一样先在 `PendingExecRegistry` 以数字 id 登记,再等待该 id 的 Bidi 结果。`McpStateExecArgs.server_identifiers` 只在请求指定 server 时填写,`kick_only = false`、`accept_hook_additional_contexts = false`。成功、错误和拒绝都生成相应 typed tool result,并以字符串内容追加到下一轮 LLM messages;不再从数据库或旧 descriptor 旁路完成。
|
||||
|
||||
请求 `790aff97-8c6a-4717-b9db-ccdae211c67c` 暴露了调用阶段的第二个协议要求:`GetMcpTools` 能正常列出 `server=plugin-browser-use-browser-use, toolName=browser_exec`,但旧服务端随后把 `McpArgs.name` 也写成 `browser_exec`、把 `provider_identifier` 写成空字符串,因此 Cursor 三次都返回 `MCP tool not found: browser_exec`。
|
||||
|
||||
官方抓包的 `McpStateExecResult` 已经给出完整定义,例如:
|
||||
|
||||
```text
|
||||
server_identifier = plugin-browser-use-browser-use
|
||||
definition.name = plugin-browser-use-browser-use-browser_exec
|
||||
provider_identifier = browser-use
|
||||
tool_name = browser_exec
|
||||
```
|
||||
|
||||
后续官方 `McpArgs` 原样使用这四个值。因此 Run 内的 MCP 定义表必须由成功的 `McpStateExecResult` 更新,以 `(server_identifier, tool_name)` 为键;`CallMcpTool` 只从这张客户端实时表取回完整 `McpToolDefinition` 并填写 Exec。不能从 server 名称截取 provider,也不能自行拼接 definition name;当精确定义不存在时,应明确要求先执行 `GetMcpTools`,不发送字段不完整的 MCP Exec。这个定义表属于 `CursorToolRuntime`,在 Run 结束时与其他 Exec 态一起释放,不读写 SQLite。
|
||||
|
||||
官方 conversation `c62e79ea-1bb2-4190-adae-cadf584d9976`(request `b0562e27-4b0e-4373-afd9-e19c74b2838e`)还给出了完整成功闭环:RunSSE frame 45/49 分别要求 `user-context7` 和 `user-codegraph` 的 MCP state;frame 157 以 `id=8` 发送 Context7 `McpArgs`,frame 184 以 `id=9` 发送 Codegraph `McpArgs`。Bidi exchange 11084 以同一 `id=8` 返回真实文本内容,exchange 11095 以 `id=9` 返回 `No results found for "main"`,两者均为 `McpResult.success`。因此 MCP 成功结果不能被压缩成 `mcp success content=N` 这类调试摘要;必须把 text、output location 和 structured content 编译成 canonical 字符串 ToolResult,`is_error` 原样保留,再进入下一轮 LLM。
|
||||
|
||||
### 33.4 证据边界
|
||||
|
||||
当前抓包已经给出 Shell、Read/Write/Edit、Delete、Glob/Grep、WebFetch、Task、AwaitShell、MCP、SwitchMode、UpdateCurrentStep 等 wire 生命周期。`WebSearch` 抓包还证明客户端只返回 approval,搜索结果由官方服务端产生;`GenerateImage` 同样属于服务端外部执行能力。它们不能伪装成本地成功,也不能仅凭 proto 编造执行器:在接入明确的搜索/图像 provider 前,现有代码只实现其 Cursor approval wire,批准后仍必须显式报未配置的服务端能力,而不是产生虚假 ToolResult。
|
||||
|
||||
### 33.5 子代理/队列恢复中的 Run 身份
|
||||
|
||||
本地异常样本显示,`001e763b-fcd4-4945-969f-57721dd827d2` 是根 Run;它派生了四个独立子 Run:`dd0971a8…`(explore)、`5ddee013…`(generalPurpose)、`2412aab8…`(shell)和 `dea4c0f5…`(cursor-guide)。`cursor-guide` 失败回传期间,Cursor 以新的 RunSSE/Bidi `request_id=2bfd06f0…` 发起一次尝试,但 `AgentRunRequest.run_id` 复用了根值 `001e763b…`。因此该 wire 字段不能作为 `runs.run_id` 的执行唯一键。
|
||||
|
||||
Cursor adapter 现在使用每次 RunSSE/Bidi 的 `request_id` 创建通用内部 RunId;wire `run_id` 不越过 adapter 成为 Store 主键。这样队列恢复是新执行,可以按客户端带回的 revision 取得 conversation ownership 并取消旧执行,而不会撞旧行。
|
||||
|
||||
此外,Run claim 失败发生在新执行尚未拥有数据库状态之前。该失败只能向当前客户端返回 typed Error,绝不能调用 `finish_run` 修改同 ID 的既有记录。旧实现正是违反了这一点:重复 INSERT 失败后又把仍在工作的根 `001e…` 标成 failed。现在只有 claim 成功的 Run 才有资格持久化 Completed/Cancelled/Failed 终态。
|
||||
|
||||
### 33.6 后台子代理完成通知
|
||||
|
||||
Task 首次创建子代理时,客户端 `SubagentSuccess` 已返回 `agent_id`,Task 调用参数中的 `description` 是该子代理的用户可见 name。两者必须立即进入 canonical ToolResult 字符串:
|
||||
|
||||
```text
|
||||
Subagent name: {description}
|
||||
Subagent ID: {agent_id}
|
||||
```
|
||||
|
||||
这条 ToolResult 表达“Task 创建出了哪个对象”,即使后台 Task 此时没有 `final_message` 也不能返回空字符串;否则 Cursor typed UI 虽持有 `TaskSuccess.agent_id`,下一轮 LLM 却不知道刚创建的子代理身份,只能从 transcript 文件或后续 completion 猜测。若首次创建时已经有 `final_message`,它接在身份之后。`resume={已有 agent_id}` 不是创建,不重复包装身份,仍只返回本次执行结果;`resume=self` 会创建新子代理,因此使用新返回的 name 和 ID。
|
||||
|
||||
官方抓包确认,后台子代理结束后客户端会为父 conversation 发起新的 RunSSE/Bidi。该 `AgentRunRequest.action` 不是普通 `user_message_action`,而是 `background_task_completion_action`;每个 completion 明确携带 `task_id`、`subagent_id`、父 `tool_call_id`、`title`、`status`、`reason`、`detail` 和 transcript `output_path`。服务端不应自行轮询子 Run,也不应从 Task 文本猜测哪个子代理完成。
|
||||
|
||||
当 `kind = SUBAGENT` 且 `reason = TASK_FINISHED` 时,completion 的 detail 先成为本轮模型可见的完成上下文,随后以 user/runtime 身份追加官方完整版 follow-up:
|
||||
|
||||
```text
|
||||
Perform any necessary follow-up actions in response to the subagent completion above. If no follow-up work is needed, no further action is required. If you mention an agent or subagent in your response, link it with the `[Name](id)` Don't use generic label such as `[agent]`, `[worker]`, or `[subagent]`. For cloud subagents, when the agent has edited code, link to `[Review](bc-id#changes)`, or, if you know the exact added and deleted line counts, `[Review +A −D](bc-id#changes)`, replacing A and D with those counts. Never write A or D literally. Use `[Try Live](bc-id#desktop)` only when the agent used computer use. Don't repeat the same confirmation every time.
|
||||
```
|
||||
|
||||
抓包中四个后台子代理依次完成时,客户端发起了四个 completion Run,以上完整提醒也出现四次。它不是 conversation 级一次性提示,而是每个完成事件各追加一次;幂等键为 `subagent-completed:{subagent_id}`。同一 completion 重试不会产生第二条 message,不同子代理完成则保持原始时间顺序继续追加。
|
||||
|
||||
```text
|
||||
后台 Task 启动,父 Turn 结束
|
||||
→ 子代理完成
|
||||
→ 客户端发送 background_task_completion_action
|
||||
→ 服务端验证 SUBAGENT + TASK_FINISHED + subagent_id
|
||||
→ 持久化完成 detail 与完整 follow-up user/runtime message
|
||||
→ checkpoint 确认
|
||||
→ 父 conversation 新一轮 LLM
|
||||
```
|
||||
|
||||
该事件同时生成 `is_simulated_msg = true`、`simulated_msg_reason = BACKGROUND_TASK_COMPLETION` 的 Cursor UserMessage/Turn,因此 UI、Blob 图和模型上下文表达同一事实。服务端此前虽然能读取普通 UserMessage 的 `subagent_system_reminder`,却完全忽略 `background_task_completion_action`;这正是后台子代理完成后父代理不会自然汇报的原因。
|
||||
|
||||
runtime message 的 checkpoint wire ID 是稳定身份 `runtime:{event_id}`,恢复时必须原样保留。`cursor-root:{blob_id}:{ordinal}` 只用于 wire ID 会重复、仅表达投射位置的普通 Cursor message,例如 assistant 的 `id = "1"`;不能替换 runtime 身份。请求 `9c1b5252-38a9-4829-87f5-2d2dda3ea37c` 的失败正是因为恢复代码把已有 `runtime:subagent-completed:{subagent_id}` 改成了位置 ID:数据库按相同 `runtime_event_id` 找到旧事件,却发现完整 canonical message 的 `message_id` 已改变,于是正确拒绝“同一事件、不同内容”。修复应恢复稳定身份,不能放宽唯一约束、覆盖旧消息或吞掉冲突。
|
||||
|
||||
### 33.7 编辑历史消息与活动后缀截断
|
||||
|
||||
`UserMessageAction` 没有 `edited` 标志;协议提供的稳定逻辑身份是 `UserMessage.message_id`。Cursor 在用户修改历史消息后会复用这个 ID 并发送新的内容。它不能继续直接充当不可变 canonical message 的主键,否则同 ID、不同 payload 会触发 `message id or runtime event reused with different content`。
|
||||
|
||||
服务端把客户端逻辑输入身份记为 `cursor:user:{message_id}`,并在第一次看到它时绑定“该输入追加前”的 `base_revision_id`:
|
||||
|
||||
```text
|
||||
第一次发送 M
|
||||
input anchor(M) = revision before M
|
||||
→ append immutable runtime message for this Run
|
||||
→ append assistant/tool suffix
|
||||
|
||||
编辑并再次发送 M
|
||||
→ resolve input anchor(M)
|
||||
→ conversation active head 回到 revision before M
|
||||
→ append a new immutable runtime message
|
||||
→ 生成新的 assistant/tool suffix
|
||||
```
|
||||
|
||||
因此活动上下文的实际结果就是“编辑点之前的前缀 + 修改后的用户消息 + 新后缀”。原用户消息以及它后面的 assistant/tool 消息不会进入新的 LLM 请求,也不会出现在新 checkpoint 的活动 Turn 图中。旧 revision 和不可变 Blob 不做覆盖或物理删除,仍可用于历史回滚;这里所谓删除是从当前 revision 的可达集合中删除。
|
||||
|
||||
输入 anchor 使用 `(conversation_id, input_id)` 唯一键并持久化,不能只放在 Run 内存中:编辑可能发生在进程重启后。重复请求通过同一个 anchor 得到同一 base;不同内容则形成新的不可变分支。Run claim 已具备把 conversation head 原子切到所选 base 的能力,后续 append 仍受 active Run ownership 保护。
|
||||
|
||||
@@ -1,170 +1,87 @@
|
||||
# Cursor Rust 服务端实施与验收计划
|
||||
# cursor-byok
|
||||
|
||||
## 项目说明
|
||||
`cursor-byok` 是一个基于真实 Cursor Agent 流量与 protobuf 实现的自托管服务端,用于把 Cursor 客户端接入用户指定的 LLM Provider。
|
||||
|
||||
`cursor-byok` 是一个兼容 Cursor Agent 客户端协议的自托管服务端项目,用于把 Cursor 客户端接入用户指定的 LLM Provider。项目根据真实客户端流量和提取出的 protobuf 协议实现,不依赖 Cursor 原服务保存对话状态。
|
||||
当前 Rust 服务 `cursor-server` 已实现:
|
||||
|
||||
当前 Rust 服务 `cursor-server` 实现以下完整链路:
|
||||
- Cursor `RunSSE + BidiAppend` 双向协议与 Connect envelope。
|
||||
- OpenAI Chat、OpenAI Responses、Anthropic 三种无状态流式端点。
|
||||
- `LLM → 客户端工具 → 结果提交 → 下一轮 LLM` 的通用 Loop。
|
||||
- append-only canonical messages、不可变 revision 分支和同对话 Run 抢占。
|
||||
- Cursor typed tool UI、Exec/Interaction、多阶段编辑、MCP 与子代理。
|
||||
- Blob CAS、KV GET/SET ACK、两阶段 checkpoint 和 pending ToolRound 恢复。
|
||||
- 未匹配 Cursor backend 路由原样流式转发到上游。
|
||||
- React + Vite + TypeScript + Tailwind 管理台,支持 Provider 配置、模型发现和调用明细。
|
||||
- 每次 Provider 调用的时间、模型快照、状态和 authoritative usage;详细模式保存脱敏请求与原始流响应。
|
||||
|
||||
- 通过 `RunSSE + BidiAppend` 组成的双向协议与 Cursor 客户端通信。
|
||||
- 将 OpenAI Chat、OpenAI Responses 和 Anthropic 的流式响应统一为内部 `ResponseEvent`。
|
||||
- 运行无状态 LLM Loop:`LLM → 客户端工具执行 → 结果追加 → 下一轮 LLM`,直到 Turn 完成或被新 Run 打断。
|
||||
- 以 append-only messages 作为上下文唯一事实源,保证相邻 LLM 请求的稳定前缀和可重复投射。
|
||||
- 支持文本、thinking、tool start、参数增量、tool result、usage、model、rules、commands、skills、MCP 和 subagent 上下文。
|
||||
- 使用 SQLite 持久化 messages、Run 状态、Blob CAS、引用边和 outbox。
|
||||
- 使用不可变 Blob 对象图表达 Conversation、Turn、UserMessage 和 Steps;BlobID 为原始内容的 `SHA-256`。
|
||||
- 在客户端确认 Blob 已存储后发布 checkpoint,并提供单工具粒度的历史回滚和未确认操作恢复。
|
||||
启动方式见 [cursor-server/README.md](./cursor-server/README.md)。协议证据见 [Cursor上下文与状态同步抓包分析.md](./Cursor上下文与状态同步抓包分析.md),当前实现约束见 [一次性重构计划计划.md](./docs/一次性重构计划计划.md)。
|
||||
|
||||
运行时职责划分如下:Cursor 客户端负责真正执行本地工具并保存服务端同步的 Blob;`cursor-server` 负责 Loop 决策、上下文投射、Provider 调用、状态持久化和 checkpoint 构造。Todo/Plan 等业务状态不单独维护,而是从 messages 确定性推导。
|
||||
|
||||
协议与状态模型的抓包结论见 [Cursor上下文与状态同步抓包分析.md](./Cursor上下文与状态同步抓包分析.md)。Rust 服务的启动方式和运行配置见 [cursor-server/README.md](./cursor-server/README.md)。
|
||||
|
||||
## 目录硬约束
|
||||
|
||||
下列目录、文件名和职责是实现验收条件,不是建议。首版只允许一个 `cursor-server` crate;代码必须落在对应文件,不得用 `core.rs`、`service.rs` 等总入口替代,也不得提前创建 MCP/subagent 空模块。新增文件必须说明为何现有职责无法容纳;删除、改名或移动下列文件必须先同步修改本计划。
|
||||
## 核心数据流
|
||||
|
||||
```text
|
||||
cursor-byok/
|
||||
├── cursor-server/ # 新 Rust 服务
|
||||
│ ├── Cargo.toml
|
||||
│ ├── build.rs # 从 cursor-proto/proto 生成 prost 类型
|
||||
│ ├── README.md # 启动方式、架构和核心不变量
|
||||
│ │
|
||||
│ ├── migrations/
|
||||
│ │ └── 0001_initial.sql # Blob、messages、runs、outbox
|
||||
│ │
|
||||
│ ├── src/
|
||||
│ │ ├── main.rs # 进程入口
|
||||
│ │ ├── lib.rs # 模块出口
|
||||
│ │ ├── app.rs # 依赖组装、启动和关闭
|
||||
│ │ ├── config.rs # 地址、数据库、provider 配置
|
||||
│ │ ├── error.rs # 服务统一错误
|
||||
│ │ │
|
||||
│ │ ├── model/ # 纯领域类型,不依赖 Cursor/provider
|
||||
│ │ │ ├── mod.rs
|
||||
│ │ │ ├── message.rs # CanonicalMessage、Role、Origin
|
||||
│ │ │ ├── runtime_tag.rs # RuntimeEvent、exactly-once 约束
|
||||
│ │ │ ├── conversation.rs # Conversation、Turn、revision
|
||||
│ │ │ ├── tool.rs # ToolCall、ToolResult
|
||||
│ │ │ └── usage.rs # provider usage 与 Turn usage
|
||||
│ │ │
|
||||
│ │ ├── run/ # Loop 引擎和一次 request 的状态机
|
||||
│ │ │ ├── mod.rs
|
||||
│ │ │ ├── registry.rs # request_id → RunHandle
|
||||
│ │ │ ├── actor.rs # 每个 Run 一个 actor
|
||||
│ │ │ ├── command.rs # run_request、exec/KV result、abort
|
||||
│ │ │ ├── inbox.rs # append_seqno 排序、去重
|
||||
│ │ │ ├── loop_engine.rs # LLM → Tool → LLM 主循环
|
||||
│ │ │ └── lifecycle.rs # turn_ended/checkpoint/EndStream
|
||||
│ │ │
|
||||
│ │ ├── cursor/ # Cursor 协议适配器
|
||||
│ │ │ ├── mod.rs
|
||||
│ │ │ ├── proto.rs # include prost 生成代码
|
||||
│ │ │ ├── connect.rs # 5-byte Connect envelope
|
||||
│ │ │ ├── handlers.rs # Axum 路由入口
|
||||
│ │ │ ├── bidi_append.rs # 上行 AgentClientMessage
|
||||
│ │ │ ├── run_sse.rs # 下行 AgentServerMessage
|
||||
│ │ │ ├── interaction.rs # 交互事件、Tool args 和 usage 投射
|
||||
│ │ │ ├── exec.rs # Exec 上行/下行解析
|
||||
│ │ │ ├── pending.rs # Exec/Interaction 的运行期 ID 关联
|
||||
│ │ │ ├── tools.rs # 唯一工具路由、本地工具和 Cursor step index
|
||||
│ │ │ ├── tool_result.rs # typed result、UI completion 和结果通道
|
||||
│ │ │ ├── blob_sync.rs # KV GET/SET、ACK、重试
|
||||
│ │ │ └── checkpoint.rs # Blob 图和 checkpoint 构造
|
||||
│ │ │
|
||||
│ │ ├── provider/ # LLM 端点适配器
|
||||
│ │ │ ├── mod.rs # Provider trait
|
||||
│ │ │ ├── event.rs # Canonical ResponseEvent
|
||||
│ │ │ ├── openai_chat.rs # 第一条可运行链路
|
||||
│ │ │ ├── openai_responses.rs
|
||||
│ │ │ └── anthropic.rs
|
||||
│ │ │
|
||||
│ │ ├── prompting/ # 模型请求编译
|
||||
│ │ │ ├── mod.rs
|
||||
│ │ │ ├── assets.rs # 校验并嵌入根目录 prompt/
|
||||
│ │ │ ├── compiler.rs # messages + mode + tools
|
||||
│ │ │ ├── projector.rs # CanonicalMessage → provider 格式
|
||||
│ │ │ └── derived_state.rs # 从 messages fold Todo/Plan
|
||||
│ │ │
|
||||
│ │ └── store/ # SQLite 持久化
|
||||
│ │ ├── mod.rs
|
||||
│ │ ├── sqlite.rs # pool、事务、PRAGMA
|
||||
│ │ ├── messages.rs # append-only messages
|
||||
│ │ ├── blobs.rs # CAS 与引用边
|
||||
│ │ ├── conversations.rs # conversation head/revision
|
||||
│ │ ├── runs.rs # 活动 Run 和恢复信息
|
||||
│ │ └── outbox.rs # KV/checkpoint 待确认操作
|
||||
│ │
|
||||
│ └── tests/
|
||||
│ ├── support/
|
||||
│ │ ├── fake_provider.rs
|
||||
│ │ ├── fake_cursor.rs
|
||||
│ │ └── fixtures.rs
|
||||
│ ├── text_turn.rs # 纯文本完整 Turn
|
||||
│ ├── tool_loop.rs # LLM → Tool → LLM
|
||||
│ ├── runtime_tag_once.rs # Runtime tag 不重复追加
|
||||
│ ├── prefix_stability.rs # M(n) 是 M(n+1) 前缀
|
||||
│ ├── checkpoint_recovery.rs # 单 Tool 回滚
|
||||
│ ├── interrupt.rs # 新 Run 打断旧 Run
|
||||
│ └── connect_wire.rs # Connect 二进制兼容性
|
||||
│
|
||||
├── cursor-proto/ # 现有 protobuf 提取和源文件
|
||||
├── cursor-backend/ # 现有 Go 抓包调试器
|
||||
├── prompt/ # 已复制的完整模式资产
|
||||
└── docs/
|
||||
HTTP / Connect
|
||||
↓
|
||||
Cursor adapter
|
||||
↓ ClientCommand / ClientEvent
|
||||
RunEngine
|
||||
↓
|
||||
canonical messages + selected revision
|
||||
↓
|
||||
typed ModelRequest
|
||||
↓
|
||||
Provider adapter → HTTP/SSE → ModelEvent
|
||||
```
|
||||
|
||||
## 按文件实施顺序与通过条件
|
||||
Loop 不依赖 Cursor protobuf、Blob、checkpoint、数字 wire id 或具体 Provider JSON。Cursor adapter 和 Provider adapter 只在各自边界做协议投射。
|
||||
|
||||
1. `Cargo.toml`、`build.rs`、`src/{main,lib,app,config,error}.rs`:服务能加载配置、迁移数据库、生成 Cursor protobuf 并启动/优雅关闭。
|
||||
2. `src/model/*.rs`、`src/store/*.rs`、`migrations/0001_initial.sql`:实现纯领域消息、runtime tag、tool/usage,以及 append-only messages、Blob CAS/引用边、revision、run 恢复与 outbox;`tests/runtime_tag_once.rs` 和 `tests/prefix_stability.rs` 必须通过。
|
||||
3. `src/cursor/{proto,connect,handlers,bidi_append,run_sse}.rs`:实现抓包一致的 5-byte Connect envelope、二进制 RunSSE、BidiAppend 解码和 `append_seqno` 排序去重;`tests/connect_wire.rs` 必须通过。
|
||||
4. `src/provider/{event,openai_chat,openai_responses,anthropic}.rs`、`src/prompting/*.rs`:三个端点统一为 canonical `ResponseEvent`;所有 mode 的 prompt/tool 资产可加载;messages 投射幂等且保持严格前缀。
|
||||
5. `src/run/*.rs`、`src/cursor/{tools,interaction,exec,pending,tool_result}.rs`:一个 request 一个 RunActor;Loop 只处理统一 `ToolCompletion`,工具名称到 Exec/Interaction/Local 的唯一映射只存在于 `cursor/tools.rs`;完成 toolstart 占位、参数增量、客户端执行、打断与 usage。每个完成的工具必须原子追加 `assistant(tool_call) → tool(result)`,整批完成前不得进入下一次 LLM。
|
||||
6. `src/cursor/{blob_sync,checkpoint}.rs`、`src/store/{blobs,outbox}.rs`:构造不可变 Blob 对象图,KV SET 未 ACK 前不得发布引用它的 checkpoint;checkpoint 达到单工具粒度,最终状态重复发布后再 EndStream;`tests/checkpoint_recovery.rs` 必须通过。
|
||||
7. `tests/{text_turn,tool_loop,interrupt}.rs` 与 `tests/support/*.rs`:覆盖纯文本 Turn、完整工具循环、新 Run 打断旧 Run和恢复路径。最终验收命令固定为 `cargo fmt --check`、`cargo clippy --all-targets -- -D warnings`、`cargo test --all-targets`。
|
||||
## 状态与 checkpoint
|
||||
|
||||
- SQLite 中的 immutable messages 与 revision 父链是对话事实源;回滚只选择旧 revision 并建立新分支。
|
||||
- ToolRound 保存完整 assistant、原始 call 顺序和真实 result 完成顺序;结果未齐时不会把悬空 tool call 投给下一轮模型。
|
||||
- BlobID 是 `SHA-256(data)`;Blob 类型来自引用字段,不编码在 ID 中。
|
||||
- checkpoint 引用的新 Blob 必须先收到对应 KV SET ACK。协议中不存在 checkpoint ACK,也不保存跨流 outbox。
|
||||
- staged checkpoint 内联完整 pending assistant;ToolRound 全部结果提交后才折叠进 stable roots。抓包没有单 ToolResult checkpoint,因此实现也不制造该状态。
|
||||
- settled checkpoint 必须先于下一轮 LLM 调用。最终文本轮严格发送 `turn_ended → staged → settled → settled 重发 → EndStream`。
|
||||
- Cursor.app 只把 `turn_ended` 前的 checkpoint 作为自动恢复候选;恢复 pending assistant 时先继续工具,不重复调用 LLM。
|
||||
|
||||
模块依赖方向固定为:
|
||||
## 目录边界
|
||||
|
||||
HTTP/Connect
|
||||
↓
|
||||
cursor adapter
|
||||
↓
|
||||
run actor
|
||||
↓
|
||||
model + prompting
|
||||
↓
|
||||
provider / client tools
|
||||
↓
|
||||
store + checkpoint
|
||||
```text
|
||||
cursor-server/src/
|
||||
├── control/ # 客户端无关的 Provider、模型、调用观测 HTTP API
|
||||
├── client/ # 所有客户端共用的最小 command/event port
|
||||
├── model/ # canonical message、revision、ModelSpec、typed history
|
||||
├── run/ # 协议无关 Loop、ModelCycle、ToolRound 和 RunRegistry
|
||||
├── provider/ # 各 LLM 端点请求与流事件适配
|
||||
├── cursor/
|
||||
│ ├── request/ # RunRequest → PreparedRun/CursorRunContext
|
||||
│ ├── prompting/ # Cursor prompt、工具 catalog 和 mode manifest
|
||||
│ ├── projection/ # Cursor AI-SDK stable/pending JSON 编解码
|
||||
│ ├── interaction/ # UI 更新、InteractionQuery、typed ToolCall 渲染
|
||||
│ ├── tools/ # Cursor 工具 transport、runtime、dispatch 和 result
|
||||
│ └── checkpoint/ # root/Turn/derived/recovery 与串行 worker
|
||||
└── store/ # SQLite messages、revision、ToolRound、Run 和 Blob CAS
|
||||
|
||||
几个关键决定:
|
||||
model/ 不引用 Cursor protobuf,也不引用具体 provider。
|
||||
cursor/ 只负责协议转换,不能包含 Loop 业务决策。
|
||||
provider/ 只把不同端点转换为统一 ResponseEvent。
|
||||
prompting/derived_state.rs 只 fold messages,不持久化 Todo/Plan。
|
||||
store/messages.rs 是上下文唯一事实源。
|
||||
store/outbox.rs 保存尚未确认的 Blob/checkpoint 操作。
|
||||
MCP 和 subagent 暂时不建空目录:MCP 先作为动态 Tool 接入;subagent 复用 RunActor,需求落地时再加入 run/subagent.rs。
|
||||
|
||||
依赖建议:
|
||||
```
|
||||
tokio 异步运行时
|
||||
axum + hyper Connect HTTP 服务
|
||||
prost + prost-build protobuf
|
||||
protoc-bin-vendored 避免系统 protoc 依赖
|
||||
sqlx/sqlite 持久化和事务
|
||||
reqwest provider HTTP
|
||||
eventsource-stream provider SSE
|
||||
serde/serde_json 模型和工具 JSON
|
||||
sha2 + base64 BlobID
|
||||
bytes 二进制载荷
|
||||
tokio-util CancellationToken
|
||||
thiserror + tracing 错误和日志
|
||||
include_dir 编译期嵌入 prompt/ 资产
|
||||
console/ # React 管理台;只依赖 control API,不依赖 Cursor protobuf
|
||||
```
|
||||
|
||||
Cursor上下文与状态同步抓包分析.md 来告诉你很多信息,你需要一次性读他
|
||||
详细到文件的目标目录和验收项只在重构计划中维护,README 不复制第二份易漂移的完整文件清单。
|
||||
|
||||
Users/leokun/Library/Application Support/cursor-byok/cursor-proxy-debugger.db 是cursor的原服务抓包信息,内容由/Users/leokun/Documents/cursor-byok/cursor-backend产生
|
||||
## 工程原则
|
||||
|
||||
- 不保留旧路径、兼容层或失败后的隐式 fallback。
|
||||
- 同一状态只有一个所有者;协议层不做 Loop 决策。
|
||||
- PromptSpec、ModelSpec 和 selected revision 决定可重放的 ModelRequest;request id、时间和 model call id 不进入模型输入。
|
||||
- 前缀稳定限定在相同 PromptSpec/ModelSpec/Provider route;新 Run 切换模型或模式时只替换 Cursor system root,其他历史 message roots 继续复用。
|
||||
- Provider replay state 只回传给产生它的端点;可展示 thinking 不是跨端点 reasoning 字段。
|
||||
- Provider usage 只采用端点报告的单轮最终值,不自行估算。
|
||||
- 同一个取消信号覆盖等待 HTTP 响应头和读取 SSE 两段。
|
||||
|
||||
## 验证
|
||||
|
||||
```bash
|
||||
cd cursor-server
|
||||
cargo fmt --check
|
||||
cargo clippy --all-targets -- -D warnings
|
||||
cargo test --all-targets
|
||||
```
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-CN">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Cursor BYOK</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
Generated
+2533
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,26 @@
|
||||
{
|
||||
"name": "cursor-byok-console",
|
||||
"private": true,
|
||||
"version": "0.1.0",
|
||||
"type": "module",
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"typecheck": "tsc -b --pretty false"
|
||||
},
|
||||
"dependencies": {
|
||||
"@tanstack/react-query": "^5.90.0",
|
||||
"react": "^19.2.0",
|
||||
"react-dom": "^19.2.0",
|
||||
"react-router-dom": "^7.9.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/vite": "^4.1.0",
|
||||
"@types/react": "^19.2.0",
|
||||
"@types/react-dom": "^19.2.0",
|
||||
"@vitejs/plugin-react": "^5.0.0",
|
||||
"tailwindcss": "^4.1.0",
|
||||
"typescript": "~5.9.0",
|
||||
"vite": "^7.1.0"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import type { CallDetail, LlmCall, Provider, ProviderInput, ProviderModel } from './types'
|
||||
|
||||
async function request<T>(path: string, init?: RequestInit): Promise<T> {
|
||||
const response = await fetch(path, {
|
||||
...init,
|
||||
headers: { 'content-type': 'application/json', ...init?.headers },
|
||||
})
|
||||
if (!response.ok) {
|
||||
const error = await response.json().catch(() => ({ message: response.statusText }))
|
||||
throw new Error(error.message ?? `HTTP ${response.status}`)
|
||||
}
|
||||
if (response.status === 204) return undefined as T
|
||||
return response.json() as Promise<T>
|
||||
}
|
||||
|
||||
export const api = {
|
||||
providers: () => request<Provider[]>('/api/providers'),
|
||||
createProvider: (input: ProviderInput) =>
|
||||
request<Provider>('/api/providers', { method: 'POST', body: JSON.stringify(input) }),
|
||||
updateProvider: (id: number, input: ProviderInput) =>
|
||||
request<Provider>(`/api/providers/${id}`, { method: 'PUT', body: JSON.stringify(input) }),
|
||||
deleteProvider: (id: number) => request<void>(`/api/providers/${id}`, { method: 'DELETE' }),
|
||||
discoverModels: (id: number) =>
|
||||
request<{ models: string[] }>(`/api/providers/${id}/models/discover`, { method: 'POST' }),
|
||||
saveModels: (id: number, models: unknown[]) =>
|
||||
request<ProviderModel[]>(`/api/providers/${id}/models`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ models }),
|
||||
}),
|
||||
models: () => request<ProviderModel[]>('/api/models'),
|
||||
deleteModel: (hash: string) => request<void>(`/api/models/${hash}`, { method: 'DELETE' }),
|
||||
calls: () => request<LlmCall[]>('/api/llm-calls'),
|
||||
call: (id: string) => request<CallDetail>(`/api/llm-calls/${encodeURIComponent(id)}`),
|
||||
observability: () => request<{ detailed: boolean }>('/api/settings/observability'),
|
||||
setObservability: (detailed: boolean) => request<{ detailed: boolean }>('/api/settings/observability', {
|
||||
method: 'PUT', body: JSON.stringify({ detailed }),
|
||||
}),
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
export type ProviderType = 'openai-chat' | 'openai-responses' | 'anthropic'
|
||||
|
||||
export interface Provider {
|
||||
provider_id: number
|
||||
name: string
|
||||
provider_type: ProviderType
|
||||
base_url: string
|
||||
has_api_key: boolean
|
||||
custom_headers: Record<string, string | null>
|
||||
created_at_ms: number
|
||||
updated_at_ms: number
|
||||
}
|
||||
|
||||
export interface ProviderInput {
|
||||
name: string
|
||||
provider_type: ProviderType
|
||||
base_url: string
|
||||
api_key?: string
|
||||
custom_headers: Record<string, string | null>
|
||||
}
|
||||
|
||||
export interface ProviderModel {
|
||||
model_hash: string
|
||||
provider_id: number
|
||||
model_id: string
|
||||
display_name: string
|
||||
enabled: boolean
|
||||
sort_order: number
|
||||
context_window_tokens?: number
|
||||
max_output_tokens?: number
|
||||
reasoning_enabled: boolean
|
||||
reasoning_effort?: string
|
||||
extra_params: Record<string, unknown>
|
||||
created_at_ms: number
|
||||
updated_at_ms: number
|
||||
}
|
||||
|
||||
export interface LlmCall {
|
||||
call_id: string
|
||||
run_id: string
|
||||
conversation_id: string
|
||||
model_hash?: string
|
||||
model_id: string
|
||||
display_name: string
|
||||
provider_type: ProviderType
|
||||
status: string
|
||||
created_at_ms: number
|
||||
duration_ms?: number
|
||||
ttfb_ms?: number
|
||||
ttft_ms?: number
|
||||
input_tokens?: number
|
||||
output_tokens?: number
|
||||
total_tokens?: number
|
||||
detailed: boolean
|
||||
}
|
||||
|
||||
export interface CallDetail {
|
||||
call: LlmCall
|
||||
request?: { headers: Record<string, string>; body: unknown; byte_count: number }
|
||||
response_chunks: { seq: number; received_offset_ms: number; data: string; byte_count: number }[]
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NavLink, Route, Routes } from 'react-router-dom'
|
||||
|
||||
import { CallDetailPage } from '../features/calls/CallDetailPage'
|
||||
import { CallsPage } from '../features/calls/CallsPage'
|
||||
import { ModelsPage } from '../features/models/ModelsPage'
|
||||
import { ProvidersPage } from '../features/providers/ProvidersPage'
|
||||
import { ObservabilityPage } from '../features/settings/ObservabilityPage'
|
||||
|
||||
const links = [
|
||||
['/', 'Providers'],
|
||||
['/models', 'Models'],
|
||||
['/calls', 'LLM Calls'],
|
||||
['/settings', 'Settings'],
|
||||
] as const
|
||||
|
||||
export function App() {
|
||||
return (
|
||||
<div className="min-h-screen bg-zinc-950 text-zinc-100">
|
||||
<header className="border-b border-zinc-800 bg-zinc-950/90">
|
||||
<div className="mx-auto flex max-w-7xl items-center gap-8 px-6 py-4">
|
||||
<div className="text-lg font-semibold">Cursor BYOK</div>
|
||||
<nav className="flex gap-2">
|
||||
{links.map(([to, label]) => (
|
||||
<NavLink
|
||||
key={to}
|
||||
to={to}
|
||||
className={({ isActive }) =>
|
||||
`rounded-md px-3 py-2 text-sm ${isActive ? 'bg-zinc-800 text-white' : 'text-zinc-400 hover:text-white'}`
|
||||
}
|
||||
>
|
||||
{label}
|
||||
</NavLink>
|
||||
))}
|
||||
</nav>
|
||||
</div>
|
||||
</header>
|
||||
<main className="mx-auto max-w-7xl px-6 py-8">
|
||||
<Routes>
|
||||
<Route path="/" element={<ProvidersPage />} />
|
||||
<Route path="/models" element={<ModelsPage />} />
|
||||
<Route path="/calls" element={<CallsPage />} />
|
||||
<Route path="/calls/:callId" element={<CallDetailPage />} />
|
||||
<Route path="/settings" element={<ObservabilityPage />} />
|
||||
</Routes>
|
||||
</main>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
import { useParams } from 'react-router-dom'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
|
||||
export function CallDetailPage() {
|
||||
const { callId = '' } = useParams()
|
||||
const detail = useQuery({ queryKey: ['call', callId], queryFn: () => api.call(callId) })
|
||||
if (!detail.data) return <p>加载中…</p>
|
||||
const { call, request, response_chunks: chunks } = detail.data
|
||||
return <section className="grid gap-6">
|
||||
<div><h1>{call.display_name}</h1><p><code>{call.call_id}</code> · {call.status}</p></div>
|
||||
<div className="grid gap-3 md:grid-cols-4">
|
||||
<Metric label="TTFB" value={call.ttfb_ms} suffix="ms" /><Metric label="TTFT" value={call.ttft_ms} suffix="ms" />
|
||||
<Metric label="Duration" value={call.duration_ms} suffix="ms" /><Metric label="Total tokens" value={call.total_tokens} />
|
||||
</div>
|
||||
<Payload title="Request" value={request ?? '详细模式未记录'} />
|
||||
<Payload title="Response stream" value={chunks.length ? chunks : '详细模式未记录'} />
|
||||
</section>
|
||||
}
|
||||
|
||||
function Metric({ label, value, suffix = '' }: { label: string; value?: number; suffix?: string }) {
|
||||
return <div className="rounded-xl border border-zinc-800 bg-zinc-900 p-4"><p>{label}</p><div className="mt-2 text-xl">{value ?? '—'} {value == null ? '' : suffix}</div></div>
|
||||
}
|
||||
|
||||
function Payload({ title, value }: { title: string; value: unknown }) {
|
||||
return <div><h2>{title}</h2><pre className="mt-2 max-h-[32rem] overflow-auto rounded-xl border border-zinc-800 bg-black p-4 text-xs text-zinc-300">{typeof value === 'string' ? value : JSON.stringify(value, null, 2)}</pre></div>
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import { useQuery } from '@tanstack/react-query'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
export function CallsPage() {
|
||||
const calls = useQuery({ queryKey: ['calls'], queryFn: api.calls, refetchInterval: 3000 })
|
||||
return <section><div><h1>LLM Calls</h1><p>每一行对应一次真实 Provider 请求。</p></div>
|
||||
<div className="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<table><thead><tr><th>时间</th><th>模型</th><th>状态</th><th>TTFT</th><th>耗时</th><th>Tokens</th></tr></thead>
|
||||
<tbody>{calls.data?.map((call) => <tr key={call.call_id}>
|
||||
<td><Link className="text-blue-400 hover:underline" to={`/calls/${encodeURIComponent(call.call_id)}`}>{new Date(call.created_at_ms).toLocaleString()}</Link></td><td>{call.display_name}<small>{call.model_id}</small></td><td>{call.status}</td>
|
||||
<td>{call.ttft_ms == null ? '—' : `${call.ttft_ms} ms`}</td><td>{call.duration_ms == null ? '—' : `${call.duration_ms} ms`}</td><td>{call.total_tokens ?? '—'}</td>
|
||||
</tr>)}</tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
import type { ProviderModel } from '../../api/types'
|
||||
|
||||
export function ModelsPage() {
|
||||
const client = useQueryClient()
|
||||
const models = useQuery({ queryKey: ['models'], queryFn: api.models })
|
||||
const save = useMutation({
|
||||
mutationFn: ({ model, input }: { model: ProviderModel; input: ModelEdit }) =>
|
||||
api.saveModels(model.provider_id, [{
|
||||
model_id: model.model_id, display_name: input.displayName, enabled: input.enabled, sort_order: model.sort_order,
|
||||
context_window_tokens: input.contextWindow || undefined, max_output_tokens: input.maxOutput || undefined,
|
||||
reasoning_enabled: input.reasoning, reasoning_effort: input.effort || undefined,
|
||||
extra_params: model.extra_params,
|
||||
}]),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['models'] }),
|
||||
})
|
||||
return <section><div><h1>Models</h1><p>Hash 是 Cursor 和其他客户端使用的稳定公开标识。</p></div>
|
||||
<div className="mt-6 overflow-hidden rounded-xl border border-zinc-800">
|
||||
<table><thead><tr><th>Hash / Provider ID</th><th>Display name</th><th>Context</th><th>Max output</th><th>Reasoning</th><th>状态</th><th></th></tr></thead>
|
||||
<tbody>{models.data?.map((model) => <ModelRow key={model.model_hash} model={model} onSave={(input) => save.mutate({ model, input })} />)}</tbody></table>
|
||||
</div>
|
||||
</section>
|
||||
}
|
||||
|
||||
interface ModelEdit { displayName: string; enabled: boolean; contextWindow: number; maxOutput: number; reasoning: boolean; effort: string }
|
||||
|
||||
function ModelRow({ model, onSave }: { model: ProviderModel; onSave: (input: ModelEdit) => void }) {
|
||||
const [displayName, setDisplayName] = useState(model.display_name)
|
||||
const [contextWindow, setContextWindow] = useState(model.context_window_tokens ?? 0)
|
||||
const [maxOutput, setMaxOutput] = useState(model.max_output_tokens ?? 0)
|
||||
const [reasoning, setReasoning] = useState(model.reasoning_enabled)
|
||||
const [effort, setEffort] = useState(model.reasoning_effort ?? '')
|
||||
const value = (enabled: boolean): ModelEdit => ({ displayName, enabled, contextWindow, maxOutput, reasoning, effort })
|
||||
return <tr>
|
||||
<td><code>{model.model_hash}</code><small>{model.model_id}</small></td>
|
||||
<td><input value={displayName} onChange={(event) => setDisplayName(event.target.value)} /></td>
|
||||
<td><input type="number" value={contextWindow || ''} onChange={(event) => setContextWindow(Number(event.target.value))} /></td>
|
||||
<td><input type="number" value={maxOutput || ''} onChange={(event) => setMaxOutput(Number(event.target.value))} /></td>
|
||||
<td><div className="flex items-center gap-2"><input className="h-4 w-4" type="checkbox" checked={reasoning} onChange={(event) => setReasoning(event.target.checked)} /><input placeholder="effort" value={effort} onChange={(event) => setEffort(event.target.value)} /></div></td>
|
||||
<td>{model.enabled ? 'Enabled' : 'Disabled'}</td>
|
||||
<td><div className="flex gap-2"><button onClick={() => onSave(value(model.enabled))}>保存</button><button onClick={() => onSave(value(!model.enabled))}>{model.enabled ? '停用' : '启用'}</button></div></td>
|
||||
</tr>
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
import { useState } from 'react'
|
||||
|
||||
import type { Provider, ProviderInput, ProviderType } from '../../api/types'
|
||||
|
||||
export function ProviderForm({ provider, onSave, busy }: { provider?: Provider; onSave: (value: ProviderInput) => void; busy: boolean }) {
|
||||
const [name, setName] = useState(provider?.name ?? '')
|
||||
const [providerType, setProviderType] = useState<ProviderType>(provider?.provider_type ?? 'openai-chat')
|
||||
const [baseUrl, setBaseUrl] = useState(provider?.base_url ?? 'https://api.openai.com/v1')
|
||||
const [apiKey, setApiKey] = useState('')
|
||||
const [headers, setHeaders] = useState(JSON.stringify(provider?.custom_headers ?? {}, null, 2))
|
||||
|
||||
return (
|
||||
<form
|
||||
className="grid gap-4 rounded-xl border border-zinc-800 bg-zinc-900 p-5 md:grid-cols-2"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault()
|
||||
onSave({ name, provider_type: providerType, base_url: baseUrl, api_key: apiKey || undefined, custom_headers: JSON.parse(headers) })
|
||||
}}
|
||||
>
|
||||
<Field label="名称"><input value={name} onChange={(e) => setName(e.target.value)} required /></Field>
|
||||
<Field label="类型">
|
||||
<select value={providerType} onChange={(e) => setProviderType(e.target.value as ProviderType)}>
|
||||
<option value="openai-chat">OpenAI Chat</option>
|
||||
<option value="openai-responses">OpenAI Responses</option>
|
||||
<option value="anthropic">Anthropic</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field label="Base URL"><input value={baseUrl} onChange={(e) => setBaseUrl(e.target.value)} required /></Field>
|
||||
<Field label="API Key"><input type="password" value={apiKey} onChange={(e) => setApiKey(e.target.value)} /></Field>
|
||||
<Field label="Custom headers (JSON)"><textarea value={headers} onChange={(e) => setHeaders(e.target.value)} /></Field>
|
||||
<div className="md:col-span-2"><button disabled={busy}>{busy ? '保存中…' : provider ? '保存 Provider' : '添加 Provider'}</button></div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function Field({ label, children }: React.PropsWithChildren<{ label: string }>) {
|
||||
return <label className="grid gap-2 text-sm text-zinc-400"><span>{label}</span>{children}</label>
|
||||
}
|
||||
@@ -0,0 +1,65 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
import { useState } from 'react'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
import { ProviderForm } from './ProviderForm'
|
||||
import type { Provider } from '../../api/types'
|
||||
|
||||
export function ProvidersPage() {
|
||||
const client = useQueryClient()
|
||||
const providers = useQuery({ queryKey: ['providers'], queryFn: api.providers })
|
||||
const [discoveries, setDiscoveries] = useState<Record<number, string[]>>({})
|
||||
const [editing, setEditing] = useState<Provider>()
|
||||
const create = useMutation({
|
||||
mutationFn: api.createProvider,
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['providers'] }),
|
||||
})
|
||||
const update = useMutation({
|
||||
mutationFn: ({ id, value }: { id: number; value: Parameters<typeof api.updateProvider>[1] }) => api.updateProvider(id, value),
|
||||
onSuccess: () => { setEditing(undefined); client.invalidateQueries({ queryKey: ['providers'] }) },
|
||||
})
|
||||
const remove = useMutation({
|
||||
mutationFn: api.deleteProvider,
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['providers'] }),
|
||||
})
|
||||
const discover = useMutation({
|
||||
mutationFn: api.discoverModels,
|
||||
onSuccess: (result, id) => setDiscoveries((current) => ({ ...current, [id]: result.models })),
|
||||
})
|
||||
const save = useMutation({
|
||||
mutationFn: ({ id, model }: { id: number; model: string }) => api.saveModels(id, [{
|
||||
model_id: model, display_name: model, enabled: true, sort_order: 0,
|
||||
reasoning_enabled: false, extra_params: {},
|
||||
}]),
|
||||
onSuccess: () => client.invalidateQueries({ queryKey: ['models'] }),
|
||||
})
|
||||
|
||||
return (
|
||||
<section className="grid gap-8">
|
||||
<div><h1>Provider</h1><p>配置端点并从 Provider 拉取可用模型。</p></div>
|
||||
<ProviderForm key={editing?.provider_id ?? 'new'} provider={editing} onSave={(value) => editing
|
||||
? update.mutate({ id: editing.provider_id, value })
|
||||
: create.mutate(value)} busy={create.isPending || update.isPending} />
|
||||
<div className="grid gap-4">
|
||||
{providers.data?.map((provider) => (
|
||||
<article key={provider.provider_id} className="rounded-xl border border-zinc-800 bg-zinc-900 p-5">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div><h2>{provider.name}</h2><p>{provider.provider_type} · {provider.base_url}</p></div>
|
||||
<div className="flex gap-2"><button onClick={() => setEditing(provider)}>编辑</button><button onClick={() => discover.mutate(provider.provider_id)}>拉取模型</button><button className="danger" onClick={() => remove.mutate(provider.provider_id)}>删除</button></div>
|
||||
</div>
|
||||
{discoveries[provider.provider_id] && (
|
||||
<div className="mt-4 grid gap-2 border-t border-zinc-800 pt-4">
|
||||
{discoveries[provider.provider_id].map((model) => (
|
||||
<div key={model} className="flex items-center justify-between rounded-md bg-zinc-950 px-3 py-2 text-sm">
|
||||
<code>{model}</code>
|
||||
<button onClick={() => save.mutate({ id: provider.provider_id, model })}>添加</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||
|
||||
import { api } from '../../api/client'
|
||||
|
||||
export function ObservabilityPage() {
|
||||
const client = useQueryClient()
|
||||
const settings = useQuery({ queryKey: ['observability'], queryFn: api.observability })
|
||||
const update = useMutation({ mutationFn: api.setObservability, onSuccess: () => client.invalidateQueries({ queryKey: ['observability'] }) })
|
||||
return <section><h1>Observability</h1><p>概要始终保存;详细模式额外保存脱敏请求和流响应。</p>
|
||||
<label className="mt-6 flex max-w-xl items-center justify-between rounded-xl border border-zinc-800 bg-zinc-900 p-5">
|
||||
<span><strong>详细记录</strong><p>仅影响开启后的新调用。</p></span>
|
||||
<input className="h-5 w-5" type="checkbox" checked={settings.data?.detailed ?? false} onChange={(event) => update.mutate(event.target.checked)} />
|
||||
</label>
|
||||
</section>
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
|
||||
import { StrictMode } from 'react'
|
||||
import { createRoot } from 'react-dom/client'
|
||||
import { HashRouter } from 'react-router-dom'
|
||||
|
||||
import { App } from './app/App'
|
||||
import './styles/index.css'
|
||||
|
||||
const queryClient = new QueryClient()
|
||||
|
||||
createRoot(document.getElementById('root')!).render(
|
||||
<StrictMode>
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<HashRouter>
|
||||
<App />
|
||||
</HashRouter>
|
||||
</QueryClientProvider>
|
||||
</StrictMode>,
|
||||
)
|
||||
@@ -0,0 +1,17 @@
|
||||
@import "tailwindcss";
|
||||
|
||||
@layer base {
|
||||
body { @apply m-0 bg-zinc-950 font-sans text-zinc-100 antialiased; }
|
||||
h1 { @apply text-2xl font-semibold tracking-tight; }
|
||||
h2 { @apply text-base font-semibold; }
|
||||
p { @apply mt-1 text-sm text-zinc-400; }
|
||||
input, select, textarea { @apply w-full rounded-md border border-zinc-700 bg-zinc-950 px-3 py-2 text-zinc-100 outline-none focus:border-blue-500; }
|
||||
textarea { @apply min-h-24 font-mono text-xs; }
|
||||
button { @apply rounded-md bg-blue-600 px-3 py-2 text-sm font-medium text-white hover:bg-blue-500 disabled:opacity-50; }
|
||||
table { @apply w-full border-collapse bg-zinc-900 text-left text-sm; }
|
||||
th { @apply bg-zinc-950 px-4 py-3 font-medium text-zinc-400; }
|
||||
td { @apply border-t border-zinc-800 px-4 py-3; }
|
||||
td small { @apply block text-zinc-500; }
|
||||
code { @apply font-mono text-xs text-blue-300; }
|
||||
button.danger { @apply bg-red-950 text-red-300 hover:bg-red-900; }
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"useDefineForClassFields": true,
|
||||
"lib": ["ES2022", "DOM", "DOM.Iterable"],
|
||||
"allowJs": false,
|
||||
"skipLibCheck": true,
|
||||
"esModuleInterop": true,
|
||||
"allowSyntheticDefaultImports": true,
|
||||
"strict": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"noEmit": true,
|
||||
"jsx": "react-jsx"
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"composite": true,
|
||||
"noEmit": true,
|
||||
"skipLibCheck": true,
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "Bundler",
|
||||
"allowImportingTsExtensions": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import tailwindcss from '@tailwindcss/vite'
|
||||
import react from '@vitejs/plugin-react'
|
||||
import { defineConfig } from 'vite'
|
||||
|
||||
export default defineConfig({
|
||||
base: '/console/',
|
||||
plugins: [react(), tailwindcss()],
|
||||
server: {
|
||||
port: 5173,
|
||||
proxy: { '/api': 'http://127.0.0.1:3000' },
|
||||
},
|
||||
})
|
||||
Generated
+177
@@ -23,6 +23,15 @@ version = "0.2.21"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "683d7910e743518b0e34f1186f92494becacb047c7b6bf616c96772180fef923"
|
||||
|
||||
[[package]]
|
||||
name = "android_system_properties"
|
||||
version = "0.1.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.104"
|
||||
@@ -217,6 +226,29 @@ dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.45"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327"
|
||||
dependencies = [
|
||||
"iana-time-zone",
|
||||
"js-sys",
|
||||
"num-traits",
|
||||
"wasm-bindgen",
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono-tz"
|
||||
version = "0.10.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a6139a8597ed92cf816dfb33f5dd6cf0bb93a6adc938f11039f371bc5bcd26c3"
|
||||
dependencies = [
|
||||
"chrono",
|
||||
"phf",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "compression-codecs"
|
||||
version = "0.4.38"
|
||||
@@ -240,6 +272,12 @@ version = "0.9.6"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2459377285ad874054d797f3ccebf984978aa39129f6eafde5cdc8315b612f8"
|
||||
|
||||
[[package]]
|
||||
name = "core-foundation-sys"
|
||||
version = "0.8.7"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b"
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.2.17"
|
||||
@@ -315,19 +353,24 @@ dependencies = [
|
||||
"axum",
|
||||
"base64",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"chrono-tz",
|
||||
"eventsource-stream",
|
||||
"flate2",
|
||||
"futures-util",
|
||||
"hex",
|
||||
"include_dir",
|
||||
"parking_lot",
|
||||
"prost",
|
||||
"prost-build",
|
||||
"prost-types",
|
||||
"protoc-bin-vendored",
|
||||
"regex",
|
||||
"reqwest",
|
||||
"serde",
|
||||
"serde_json",
|
||||
"sha2",
|
||||
"similar",
|
||||
"sqlx",
|
||||
"tempfile",
|
||||
"thiserror",
|
||||
@@ -709,6 +752,12 @@ dependencies = [
|
||||
"pin-project-lite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "http-range-header"
|
||||
version = "0.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9171a2ea8a68358193d15dd5d70c1c10a2afc3e7e4c5bc92bc9f025cebd7359c"
|
||||
|
||||
[[package]]
|
||||
name = "httparse"
|
||||
version = "1.10.1"
|
||||
@@ -781,6 +830,30 @@ dependencies = [
|
||||
"tracing",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone"
|
||||
version = "0.1.65"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470"
|
||||
dependencies = [
|
||||
"android_system_properties",
|
||||
"core-foundation-sys",
|
||||
"iana-time-zone-haiku",
|
||||
"js-sys",
|
||||
"log",
|
||||
"wasm-bindgen",
|
||||
"windows-core",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "iana-time-zone-haiku"
|
||||
version = "0.1.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f"
|
||||
dependencies = [
|
||||
"cc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "icu_collections"
|
||||
version = "2.2.0"
|
||||
@@ -1059,6 +1132,16 @@ version = "0.3.17"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6877bb514081ee2a7ff5ef9de3281f14a4dd4bceac4c09388074a6b5df8a139a"
|
||||
|
||||
[[package]]
|
||||
name = "mime_guess"
|
||||
version = "2.0.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f7c44f8e672c00fe5308fa235f821cb4198414e1c77935c1ab6948d3fd78550e"
|
||||
dependencies = [
|
||||
"mime",
|
||||
"unicase",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "minimal-lexical"
|
||||
version = "0.2.1"
|
||||
@@ -1216,6 +1299,24 @@ dependencies = [
|
||||
"indexmap",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "913273894cec178f401a31ec4b656318d95473527be05c0752cc41cdc32be8b7"
|
||||
dependencies = [
|
||||
"phf_shared",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "phf_shared"
|
||||
version = "0.12.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "06005508882fb681fd97892ecff4b7fd0fee13ef1aa569f8695dae7ab9099981"
|
||||
dependencies = [
|
||||
"siphasher",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "pin-project-lite"
|
||||
version = "0.2.17"
|
||||
@@ -1858,6 +1959,18 @@ version = "0.3.10"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3a219298ac11a56ea9a6d2120044824d6f01aeb034955e7af7bc16858527deea"
|
||||
|
||||
[[package]]
|
||||
name = "similar"
|
||||
version = "2.7.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa"
|
||||
|
||||
[[package]]
|
||||
name = "siphasher"
|
||||
version = "1.0.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649"
|
||||
|
||||
[[package]]
|
||||
name = "slab"
|
||||
version = "0.4.12"
|
||||
@@ -2314,6 +2427,11 @@ dependencies = [
|
||||
"http",
|
||||
"http-body",
|
||||
"http-body-util",
|
||||
"http-range-header",
|
||||
"httpdate",
|
||||
"mime",
|
||||
"mime_guess",
|
||||
"percent-encoding",
|
||||
"pin-project-lite",
|
||||
"tokio",
|
||||
"tokio-util",
|
||||
@@ -2409,6 +2527,12 @@ version = "1.20.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b6f5e870be6c3b371b77fe0ee0bafb859fa4964b4404c27de1d380043c4dda20"
|
||||
|
||||
[[package]]
|
||||
name = "unicase"
|
||||
version = "2.9.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "dbc4bc3a9f746d862c45cb89d705aa10f187bb96c76001afab07a0d35ce60142"
|
||||
|
||||
[[package]]
|
||||
name = "unicode-bidi"
|
||||
version = "0.3.18"
|
||||
@@ -2606,12 +2730,65 @@ dependencies = [
|
||||
"wasite",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-core"
|
||||
version = "0.62.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb"
|
||||
dependencies = [
|
||||
"windows-implement",
|
||||
"windows-interface",
|
||||
"windows-link",
|
||||
"windows-result",
|
||||
"windows-strings",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-implement"
|
||||
version = "0.60.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-interface"
|
||||
version = "0.59.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.119",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-link"
|
||||
version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5"
|
||||
|
||||
[[package]]
|
||||
name = "windows-result"
|
||||
version = "0.4.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-strings"
|
||||
version = "0.5.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091"
|
||||
dependencies = [
|
||||
"windows-link",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "windows-sys"
|
||||
version = "0.48.0"
|
||||
|
||||
@@ -9,16 +9,21 @@ async-stream = "0.3"
|
||||
axum = "0.8"
|
||||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
chrono = "0.4"
|
||||
chrono-tz = "0.10"
|
||||
eventsource-stream = "0.2"
|
||||
futures-util = "0.3"
|
||||
hex = "0.4"
|
||||
include_dir = "0.7"
|
||||
parking_lot = "0.12"
|
||||
prost = "0.13"
|
||||
prost-types = "0.13"
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
|
||||
regex = "1"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
sha2 = "0.10"
|
||||
similar = "2"
|
||||
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
|
||||
thiserror = "2"
|
||||
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time", "net"] }
|
||||
@@ -26,7 +31,7 @@ tokio-stream = { version = "0.1", features = ["sync"] }
|
||||
tokio-util = "0.7"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
tower-http = { version = "0.6", features = ["decompression-gzip"] }
|
||||
tower-http = { version = "0.6", features = ["decompression-gzip", "fs"] }
|
||||
|
||||
[build-dependencies]
|
||||
prost-build = "0.13"
|
||||
|
||||
+32
-12
@@ -1,6 +1,6 @@
|
||||
# cursor-server
|
||||
|
||||
Cursor Agent 的 Rust 服务端。它实现 `RunSSE + BidiAppend` 通信、无状态 LLM loop、客户端工具执行、Blob/KV 同步和可恢复 checkpoint。
|
||||
Cursor Agent 的 Rust 服务端。它实现 `RunSSE + BidiAppend` 通信、无状态 LLM loop、客户端工具执行、Blob/KV 同步和可恢复 checkpoint。服务只接管已经实现的 Cursor 接口;其他 backend 请求原样流式转发到固定上游 `https://api2.cursor.sh`。
|
||||
|
||||
## 启动
|
||||
|
||||
@@ -19,33 +19,53 @@ cargo --version
|
||||
export PATH="$(brew --prefix rustup)/bin:$HOME/.cargo/bin:$PATH"
|
||||
```
|
||||
|
||||
先构建管理台:
|
||||
|
||||
```bash
|
||||
cd console
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
进入 `cursor-server` 后启动:
|
||||
|
||||
```bash
|
||||
cd ../cursor-server
|
||||
CURSOR_DATABASE_URL=sqlite://cursor-server.db \
|
||||
CURSOR_PROVIDER=openai-chat \
|
||||
CURSOR_PROVIDER_BASE_URL=http://127.0.0.1:8317/v1 \
|
||||
CURSOR_PROVIDER_API_KEY=123456 \
|
||||
CURSOR_MODEL=deepseek-v4-flash \
|
||||
cargo run
|
||||
```
|
||||
|
||||
默认监听 `127.0.0.1:3000`。完整环境变量见 `src/config.rs`。
|
||||
默认监听 `127.0.0.1:3000`。打开 `http://127.0.0.1:3000/console/` 配置 Provider、拉取并启用模型。Provider URL、API Key 和模型不再从环境变量隐式覆盖;SQLite 是唯一运行时配置源。Anthropic 模型必须在模型配置中设置最大输出 token,服务不会猜测默认值。完整启动环境变量见 `src/config.rs`。
|
||||
|
||||
## 不变量
|
||||
|
||||
- `store/messages.rs` 是上下文唯一事实源;消息只追加,不原地修改。
|
||||
- Runtime tag 使用稳定事件 ID,事务内 exactly-once 追加。
|
||||
- 每轮投射结果可复现,后一轮 messages 严格以前一轮为前缀。
|
||||
- 不可变 messages 与 revision 父链共同构成上下文事实源;消息只追加,不原地修改,回滚只选择旧 revision 并建立新分支。
|
||||
- 每个携带用户语义的 RunRequest 生成一条 user-role/runtime-origin message;当前请求有什么上下文就加入什么,没有则省略。它使用稳定事件 ID,事务内 exactly-once 追加。
|
||||
- 同一 PromptSpec/ModelSpec/Provider route 内,每轮投射结果可复现,后一轮 messages 严格以前一轮为前缀;新 Run 切换模型或模式时只替换 Cursor system root。
|
||||
- canonical tool pairs 的 typed history 折叠属于 `model/projection.rs`;Cursor checkpoint 与各 Provider 都依赖它,彼此不反向依赖。
|
||||
- 工具每完成一个,就按实际完成顺序原子追加一组 `assistant(tool_call) → tool(result)`;投射给 LLM 的上下文没有悬空 tool call,整批完整后才继续调用 LLM。
|
||||
- Blob 是 `SHA-256(data)` 的不可变 CAS;Blob 类型来自引用字段,不编码在 BlobID 中。
|
||||
- 引用 Blob 的 checkpoint 只有在全部新 Blob 得到 KV SET ACK 后才能发布。
|
||||
- checkpoint 以单个工具为恢复粒度;最终 checkpoint 在 EndStream 前可重复发送。
|
||||
- 每个新 Blob 只对应一次 KV SET 和一个配对 ACK;拒绝、超时或同步 worker 失败直接结束当前 Cursor Run,不定时制造新 id 重试。
|
||||
- checkpoint 以完整 assistant 为 staged/settled 边界:工具批次开始时 stable roots 不变、`pending_tool_calls` 内联一条完整 assistant JSON;全部工具结果提交后才进入 stable roots。最终文本 assistant 同样走 staged/settled,并在 `turn_ended` 后重发同一 settled checkpoint;staged 与 settled 复用同一个已确认 Turn,presentation delta 不得消费两次。
|
||||
- Provider 未报告 usage 时不伪造零值;`TurnEndedUpdate` 的 token 字段保持缺省。
|
||||
- 每次真实 Provider 请求对应一条 `llm_calls`;时间使用 UTC 时间点与单调时钟耗时,usage 只保存 Provider 报告值。详细模式额外保存脱敏后的最终请求和原始 SSE 字节块。
|
||||
- 用户模型公开 ID 是规范化 `URL + NUL + provider type + NUL + modelId` 的 SHA-256 前 4 bytes,表示为 8 位小写 hex;API Key 和 displayName 不参与身份。
|
||||
- `AvailableModels` 与 `GetUsableModels` 在官方响应原始 protobuf 后追加用户模型字段,不解码重编码未知字段;`requested_model.model_id` 使用公开 ID 解析 Provider 路由。
|
||||
- 新 Run 通过 conversation revision 使旧 Run 的迟到事件失效。
|
||||
- 每种工具只对应一个 Exec、Interaction 或 Local 通道;不存在级联 fallback。
|
||||
- Loop 不保存工具名称路由;`cursor/tools.rs` 是唯一 transport dispatcher。
|
||||
- Loop 不保存工具名称路由;`cursor/tools/dispatch/` 是 transport dispatcher,`cursor/tools/runtime.rs` 唯一拥有当前 Cursor Run 的 Exec/Interaction wire-id 和 terminal tombstone。
|
||||
- Interaction approval 不是 ToolResult;只有 typed terminal result 才能进入持久化与 checkpoint。
|
||||
- Pending 项存在即 Running,终态通过 `take(id)` 一次消费;不维护重复的 finished/closed 标志。
|
||||
- Exec 与 Interaction 共用当前 Run 唯一、单调且不复用的 wire-id 空间;typed terminal result 一次消费,大 payload 在核心 commit 后释放,完成墓碑保留到 ToolRound settled。
|
||||
- prompt 资产编译进二进制并在启动时整体校验,不与运行时目录逐文件混用。
|
||||
- `prompt/cursor/tools.json` 是 Cursor 工具 schema 唯一事实源,`prompt/cursor/modes/*.json` 只定义有序工具名或明确 variant。每个模式显式维护 `{prompt.md,runtime.md}`,不使用别名或缺失资产 fallback。
|
||||
- 当前 `UserMessage.mode` 同时选择 system prompt、runtime 模板和工具集;子代理由 `subagent_type_name` 明确选择 subagent 资产,使用无 Cloud 字段的 Task variant 并增加 `UpdateCurrentStep`。
|
||||
- `GetMcpTools` 必须等待客户端 `McpStateExecResult` 的实时 MCP 状态,不能从初始 descriptor 快照本地完成。
|
||||
- 本地精确路由优先;未匹配的 method、path/query、headers 和 body 流式转发到固定 Cursor 上游,上游 status、headers 和 body 流式返回。
|
||||
- 反向代理只改写目标 authority,并剥离不能逐跳转发的 hop-by-hop headers;不存在的本地路由不能直接返回 404。
|
||||
- `cursor/checkpoint/worker.rs` 独占可推进的 checkpoint builder;`cursor/session.rs` 只提交 staged/settled/final job 并等待相应 barrier。
|
||||
- `cursor/projection/`、`cursor/interaction/` 和 `cursor/tools/codec/` 分别按 JSON 编解码、UI 消息方向和 Exec wire 方向组织,不共享运行期状态。
|
||||
- Provider 的取消同时覆盖等待 HTTP 响应头和读取 SSE,旧 Run 不会卡在尚未建立的流上。
|
||||
- Ctrl-C/SIGTERM 先停止接受新连接并取消所有 Run/工具、关闭 RunSSE;HTTP graceful shutdown 最多等待 10 秒,随后强制释放服务。
|
||||
|
||||
模块边界和目录是实现约束,必须与仓库根目录 README 保持一致。
|
||||
|
||||
Binary file not shown.
@@ -1,38 +1,121 @@
|
||||
PRAGMA foreign_keys = ON;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS conversations (
|
||||
CREATE TABLE conversations (
|
||||
conversation_id TEXT PRIMARY KEY,
|
||||
revision INTEGER NOT NULL DEFAULT 0,
|
||||
head_blob_id BLOB,
|
||||
current_revision_id INTEGER,
|
||||
active_run_id TEXT,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS messages (
|
||||
CREATE TABLE messages (
|
||||
conversation_id TEXT NOT NULL,
|
||||
message_seq INTEGER NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
role TEXT NOT NULL,
|
||||
origin TEXT NOT NULL,
|
||||
payload_json TEXT NOT NULL,
|
||||
runtime_event_id TEXT,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (conversation_id, message_seq),
|
||||
UNIQUE (conversation_id, message_id),
|
||||
UNIQUE (conversation_id, runtime_event_id),
|
||||
PRIMARY KEY (conversation_id, message_id),
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS messages_conversation_seq
|
||||
ON messages(conversation_id, message_seq);
|
||||
CREATE UNIQUE INDEX messages_runtime_event
|
||||
ON messages(conversation_id, runtime_event_id)
|
||||
WHERE runtime_event_id IS NOT NULL;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blobs (
|
||||
blob_id BLOB PRIMARY KEY CHECK(length(blob_id) = 32),
|
||||
data BLOB NOT NULL,
|
||||
CREATE TABLE conversation_revisions (
|
||||
revision_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
conversation_id TEXT NOT NULL,
|
||||
parent_revision_id INTEGER,
|
||||
state_digest BLOB NOT NULL CHECK(length(state_digest) = 32),
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
CHECK(length(data) >= 0)
|
||||
UNIQUE (conversation_id, state_digest),
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id),
|
||||
FOREIGN KEY (parent_revision_id) REFERENCES conversation_revisions(revision_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS blob_edges (
|
||||
CREATE INDEX conversation_revisions_parent
|
||||
ON conversation_revisions(conversation_id, parent_revision_id);
|
||||
|
||||
CREATE TABLE revision_messages (
|
||||
revision_id INTEGER NOT NULL,
|
||||
ordinal INTEGER NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
message_id TEXT NOT NULL,
|
||||
PRIMARY KEY (revision_id, ordinal),
|
||||
UNIQUE (revision_id, message_id),
|
||||
FOREIGN KEY (revision_id) REFERENCES conversation_revisions(revision_id),
|
||||
FOREIGN KEY (conversation_id, message_id) REFERENCES messages(conversation_id, message_id)
|
||||
);
|
||||
|
||||
CREATE TABLE runs (
|
||||
run_id TEXT PRIMARY KEY,
|
||||
conversation_id TEXT NOT NULL,
|
||||
base_revision_id INTEGER NOT NULL,
|
||||
head_revision_id INTEGER NOT NULL,
|
||||
parent_run_id TEXT,
|
||||
parent_tool_call_id TEXT,
|
||||
run_kind TEXT NOT NULL,
|
||||
subagent_kind TEXT,
|
||||
status TEXT NOT NULL,
|
||||
provider_call_index INTEGER NOT NULL DEFAULT -1,
|
||||
turn_usage_json TEXT NOT NULL DEFAULT 'null',
|
||||
failure_category TEXT,
|
||||
failure_summary TEXT,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id),
|
||||
FOREIGN KEY (base_revision_id) REFERENCES conversation_revisions(revision_id),
|
||||
FOREIGN KEY (head_revision_id) REFERENCES conversation_revisions(revision_id)
|
||||
);
|
||||
|
||||
CREATE INDEX runs_conversation_status
|
||||
ON runs(conversation_id, status);
|
||||
|
||||
CREATE TABLE tool_rounds (
|
||||
round_id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
base_revision_id INTEGER NOT NULL,
|
||||
assistant_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
version INTEGER NOT NULL DEFAULT 0,
|
||||
next_completion_seq INTEGER NOT NULL DEFAULT 0,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (run_id) REFERENCES runs(run_id),
|
||||
FOREIGN KEY (base_revision_id) REFERENCES conversation_revisions(revision_id)
|
||||
);
|
||||
|
||||
CREATE INDEX tool_rounds_run_status
|
||||
ON tool_rounds(run_id, status);
|
||||
|
||||
CREATE TABLE tool_round_calls (
|
||||
round_id TEXT NOT NULL,
|
||||
call_index INTEGER NOT NULL,
|
||||
call_id TEXT NOT NULL,
|
||||
model_call_id TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
arguments_json TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
completion_seq INTEGER,
|
||||
result_content TEXT,
|
||||
result_is_error INTEGER,
|
||||
committed_revision_id INTEGER,
|
||||
completed_at_ms INTEGER,
|
||||
PRIMARY KEY (round_id, call_index),
|
||||
UNIQUE (round_id, call_id),
|
||||
UNIQUE (round_id, completion_seq),
|
||||
FOREIGN KEY (round_id) REFERENCES tool_rounds(round_id),
|
||||
FOREIGN KEY (committed_revision_id) REFERENCES conversation_revisions(revision_id)
|
||||
);
|
||||
|
||||
CREATE TABLE blobs (
|
||||
blob_id BLOB PRIMARY KEY CHECK(length(blob_id) = 32),
|
||||
data BLOB NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE blob_edges (
|
||||
parent_blob_id BLOB NOT NULL,
|
||||
child_blob_id BLOB NOT NULL,
|
||||
field_name TEXT NOT NULL,
|
||||
@@ -41,53 +124,4 @@ CREATE TABLE IF NOT EXISTS blob_edges (
|
||||
FOREIGN KEY (child_blob_id) REFERENCES blobs(blob_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS blob_edges_child ON blob_edges(child_blob_id);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS runs (
|
||||
request_id TEXT PRIMARY KEY,
|
||||
run_id TEXT,
|
||||
conversation_id TEXT,
|
||||
revision INTEGER,
|
||||
append_seqno INTEGER NOT NULL DEFAULT -1,
|
||||
status TEXT NOT NULL,
|
||||
provider_call_index INTEGER NOT NULL DEFAULT 0,
|
||||
turn_usage_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS runs_conversation_status
|
||||
ON runs(conversation_id, status);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS run_tool_results (
|
||||
request_id TEXT NOT NULL,
|
||||
batch_index INTEGER NOT NULL,
|
||||
call_index INTEGER NOT NULL,
|
||||
completion_seq INTEGER NOT NULL,
|
||||
call_id TEXT NOT NULL,
|
||||
output_json TEXT NOT NULL,
|
||||
is_error INTEGER NOT NULL,
|
||||
completed_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (request_id, batch_index, call_index),
|
||||
UNIQUE (request_id, call_id),
|
||||
FOREIGN KEY (request_id) REFERENCES runs(request_id)
|
||||
);
|
||||
|
||||
CREATE TABLE IF NOT EXISTS outbox (
|
||||
outbox_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
request_id TEXT NOT NULL,
|
||||
operation_key TEXT NOT NULL,
|
||||
operation_kind TEXT NOT NULL,
|
||||
payload BLOB NOT NULL,
|
||||
dependency_blob_ids_json TEXT NOT NULL DEFAULT '[]',
|
||||
attempts INTEGER NOT NULL DEFAULT 0,
|
||||
acked_at_ms INTEGER,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
UNIQUE(request_id, operation_key),
|
||||
FOREIGN KEY (request_id) REFERENCES runs(request_id)
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS outbox_pending
|
||||
ON outbox(request_id, acked_at_ms, outbox_id);
|
||||
CREATE INDEX blob_edges_child ON blob_edges(child_blob_id);
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
CREATE TABLE input_anchors (
|
||||
conversation_id TEXT NOT NULL,
|
||||
input_id TEXT NOT NULL,
|
||||
base_revision_id INTEGER NOT NULL,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
PRIMARY KEY (conversation_id, input_id),
|
||||
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id),
|
||||
FOREIGN KEY (base_revision_id) REFERENCES conversation_revisions(revision_id)
|
||||
);
|
||||
@@ -0,0 +1,103 @@
|
||||
CREATE TABLE provider_endpoints (
|
||||
provider_id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
name TEXT NOT NULL,
|
||||
provider_type TEXT NOT NULL,
|
||||
base_url TEXT NOT NULL,
|
||||
api_key TEXT NOT NULL,
|
||||
custom_headers_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
CREATE TABLE provider_models (
|
||||
model_hash TEXT PRIMARY KEY CHECK(length(model_hash) = 8),
|
||||
provider_id INTEGER NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
enabled INTEGER NOT NULL DEFAULT 1,
|
||||
sort_order INTEGER NOT NULL DEFAULT 0,
|
||||
context_window_tokens INTEGER,
|
||||
max_output_tokens INTEGER,
|
||||
reasoning_enabled INTEGER NOT NULL DEFAULT 0,
|
||||
reasoning_effort TEXT,
|
||||
extra_params_json TEXT NOT NULL DEFAULT '{}',
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL,
|
||||
UNIQUE(provider_id, model_id),
|
||||
FOREIGN KEY(provider_id) REFERENCES provider_endpoints(provider_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE INDEX provider_models_enabled_sort
|
||||
ON provider_models(enabled, sort_order, display_name);
|
||||
|
||||
CREATE TABLE service_settings (
|
||||
setting_key TEXT PRIMARY KEY,
|
||||
value_json TEXT NOT NULL,
|
||||
updated_at_ms INTEGER NOT NULL
|
||||
);
|
||||
|
||||
INSERT INTO service_settings(setting_key, value_json, updated_at_ms)
|
||||
VALUES ('llm_detailed_logging', 'false', unixepoch('subsec') * 1000);
|
||||
|
||||
CREATE TABLE llm_calls (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
run_id TEXT NOT NULL,
|
||||
conversation_id TEXT NOT NULL,
|
||||
provider_call_index INTEGER NOT NULL,
|
||||
model_hash TEXT,
|
||||
provider_type TEXT NOT NULL,
|
||||
provider_url TEXT NOT NULL,
|
||||
model_id TEXT NOT NULL,
|
||||
display_name TEXT NOT NULL,
|
||||
status TEXT NOT NULL,
|
||||
finish_reason TEXT,
|
||||
created_at_ms INTEGER NOT NULL,
|
||||
request_started_at_ms INTEGER,
|
||||
response_headers_at_ms INTEGER,
|
||||
first_event_at_ms INTEGER,
|
||||
first_text_at_ms INTEGER,
|
||||
finished_at_ms INTEGER,
|
||||
queue_ms INTEGER,
|
||||
ttfb_ms INTEGER,
|
||||
ttft_ms INTEGER,
|
||||
duration_ms INTEGER,
|
||||
input_tokens INTEGER,
|
||||
output_tokens INTEGER,
|
||||
total_tokens INTEGER,
|
||||
cache_read_tokens INTEGER,
|
||||
cache_write_tokens INTEGER,
|
||||
reasoning_tokens INTEGER,
|
||||
usage_json TEXT,
|
||||
message_count INTEGER NOT NULL,
|
||||
tool_count INTEGER NOT NULL,
|
||||
request_bytes INTEGER,
|
||||
response_bytes INTEGER NOT NULL DEFAULT 0,
|
||||
stream_event_count INTEGER NOT NULL DEFAULT 0,
|
||||
http_status INTEGER,
|
||||
error_kind TEXT,
|
||||
error_message TEXT,
|
||||
detailed INTEGER NOT NULL,
|
||||
FOREIGN KEY(model_hash) REFERENCES provider_models(model_hash)
|
||||
);
|
||||
|
||||
CREATE INDEX llm_calls_created ON llm_calls(created_at_ms DESC);
|
||||
CREATE INDEX llm_calls_run ON llm_calls(run_id, provider_call_index);
|
||||
CREATE INDEX llm_calls_model ON llm_calls(model_hash, created_at_ms DESC);
|
||||
|
||||
CREATE TABLE llm_call_requests (
|
||||
call_id TEXT PRIMARY KEY,
|
||||
headers_json TEXT NOT NULL,
|
||||
body_json TEXT NOT NULL,
|
||||
byte_count INTEGER NOT NULL,
|
||||
FOREIGN KEY(call_id) REFERENCES llm_calls(call_id) ON DELETE CASCADE
|
||||
);
|
||||
|
||||
CREATE TABLE llm_call_response_chunks (
|
||||
call_id TEXT NOT NULL,
|
||||
seq INTEGER NOT NULL,
|
||||
received_offset_ms INTEGER NOT NULL,
|
||||
data BLOB NOT NULL,
|
||||
byte_count INTEGER NOT NULL,
|
||||
PRIMARY KEY(call_id, seq),
|
||||
FOREIGN KEY(call_id) REFERENCES llm_calls(call_id) ON DELETE CASCADE
|
||||
);
|
||||
+41
-11
@@ -1,10 +1,17 @@
|
||||
use std::{future::IntoFuture, time::Duration};
|
||||
|
||||
use tokio::net::TcpListener;
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
config::Config,
|
||||
cursor::handlers,
|
||||
prompting::{PromptAssets, PromptCompiler},
|
||||
provider::build_provider,
|
||||
control,
|
||||
cursor::{
|
||||
handlers,
|
||||
prompting::{PromptAssets, PromptCompiler},
|
||||
CursorSessionRegistry,
|
||||
},
|
||||
provider::ProviderRouter,
|
||||
run::RunRegistry,
|
||||
store::Store,
|
||||
Result,
|
||||
@@ -13,7 +20,7 @@ use crate::{
|
||||
pub struct App {
|
||||
config: Config,
|
||||
router: axum::Router,
|
||||
registry: RunRegistry,
|
||||
registry: CursorSessionRegistry,
|
||||
}
|
||||
|
||||
impl App {
|
||||
@@ -21,10 +28,15 @@ impl App {
|
||||
let store = Store::connect(&config.database_url).await?;
|
||||
let assets = PromptAssets::embedded()?;
|
||||
let compiler = PromptCompiler::new(assets);
|
||||
let provider = build_provider(&config.provider)?;
|
||||
let registry = RunRegistry::new(store, provider, compiler, config.provider.model.clone());
|
||||
let provider = std::sync::Arc::new(ProviderRouter::new(
|
||||
store.clone(),
|
||||
config.provider_request_timeout,
|
||||
));
|
||||
let run_registry = RunRegistry::default();
|
||||
let registry = CursorSessionRegistry::new(store.clone(), provider, compiler, run_registry);
|
||||
let router = handlers::router(registry.clone())?.merge(control::router(store));
|
||||
Ok(Self {
|
||||
router: handlers::router(registry.clone()),
|
||||
router,
|
||||
registry,
|
||||
config,
|
||||
})
|
||||
@@ -34,14 +46,31 @@ impl App {
|
||||
let listener = TcpListener::bind(self.config.listen_addr).await?;
|
||||
tracing::info!(address = %self.config.listen_addr, "cursor server listening");
|
||||
let registry = self.registry;
|
||||
axum::serve(listener, self.router)
|
||||
.with_graceful_shutdown(shutdown_signal(registry))
|
||||
.await?;
|
||||
let shutdown = CancellationToken::new();
|
||||
let graceful = shutdown.clone();
|
||||
let server = axum::serve(listener, self.router)
|
||||
.with_graceful_shutdown(async move {
|
||||
graceful.cancelled().await;
|
||||
})
|
||||
.into_future();
|
||||
tokio::pin!(server);
|
||||
|
||||
let signal = shutdown_signal(registry, shutdown);
|
||||
tokio::pin!(signal);
|
||||
tokio::select! {
|
||||
result = &mut server => result?,
|
||||
() = &mut signal => {
|
||||
match tokio::time::timeout(Duration::from_secs(10), &mut server).await {
|
||||
Ok(result) => result?,
|
||||
Err(_) => tracing::warn!("graceful shutdown timed out; forcing server close"),
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn shutdown_signal(registry: RunRegistry) {
|
||||
async fn shutdown_signal(registry: CursorSessionRegistry, shutdown: CancellationToken) {
|
||||
let ctrl_c = async {
|
||||
let _ = tokio::signal::ctrl_c().await;
|
||||
};
|
||||
@@ -57,5 +86,6 @@ async fn shutdown_signal(registry: RunRegistry) {
|
||||
let terminate = std::future::pending::<()>();
|
||||
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
|
||||
tracing::info!("shutdown signal received; cancelling active runs");
|
||||
shutdown.cancel();
|
||||
registry.shutdown().await;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
use crate::model::RuntimeEvent;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum ClientCommand {
|
||||
ToolResult {
|
||||
call_id: String,
|
||||
content: String,
|
||||
is_error: bool,
|
||||
},
|
||||
RuntimeEvent(RuntimeEvent),
|
||||
ClientClosed {
|
||||
error: String,
|
||||
},
|
||||
Cancel,
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::model::{RevisionId, ToolCall, ToolRoundId, Usage};
|
||||
use crate::run::RunOutcome;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub enum CommitCause {
|
||||
InitialMessages,
|
||||
ToolRoundStarted(ToolRoundId),
|
||||
ToolResult { call_id: String },
|
||||
FinalTurn,
|
||||
RuntimeEvent { event_id: String },
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum CommitBarrier {
|
||||
None,
|
||||
BeforeContinue(oneshot::Sender<std::result::Result<(), String>>),
|
||||
}
|
||||
|
||||
impl CommitBarrier {
|
||||
pub fn before_continue() -> (Self, oneshot::Receiver<std::result::Result<(), String>>) {
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
(Self::BeforeContinue(sender), receiver)
|
||||
}
|
||||
|
||||
pub fn is_required(&self) -> bool {
|
||||
matches!(self, Self::BeforeContinue(_))
|
||||
}
|
||||
|
||||
pub fn complete(self, result: std::result::Result<(), String>) {
|
||||
if let Self::BeforeContinue(sender) = self {
|
||||
let _ = sender.send(result);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub struct StateCommitted {
|
||||
pub revision_id: RevisionId,
|
||||
pub tool_round_version: u64,
|
||||
pub cause: CommitCause,
|
||||
pub barrier: CommitBarrier,
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum ClientEvent {
|
||||
TextStart,
|
||||
TextDelta(String),
|
||||
TextEnd,
|
||||
ThinkingStart,
|
||||
ThinkingDelta(String),
|
||||
ThinkingEnd {
|
||||
duration: Duration,
|
||||
},
|
||||
ToolCallStart {
|
||||
index: usize,
|
||||
call_id: String,
|
||||
name: String,
|
||||
model_call_id: String,
|
||||
},
|
||||
ToolCallArgumentsDelta {
|
||||
index: usize,
|
||||
delta: String,
|
||||
},
|
||||
ToolCallEnd {
|
||||
index: usize,
|
||||
},
|
||||
Usage(Usage),
|
||||
ExecuteToolRound {
|
||||
round_id: ToolRoundId,
|
||||
calls: Vec<ToolCall>,
|
||||
},
|
||||
StateCommitted(StateCommitted),
|
||||
Ended(RunOutcome),
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
mod command;
|
||||
mod event;
|
||||
mod session;
|
||||
|
||||
pub use command::*;
|
||||
pub use event::*;
|
||||
pub use session::*;
|
||||
@@ -0,0 +1,28 @@
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use super::{ClientCommand, ClientEvent};
|
||||
|
||||
pub struct ClientPort {
|
||||
pub commands: mpsc::Receiver<ClientCommand>,
|
||||
pub events: mpsc::Sender<ClientEvent>,
|
||||
}
|
||||
|
||||
pub struct ClientSession {
|
||||
pub commands: mpsc::Sender<ClientCommand>,
|
||||
pub events: mpsc::Receiver<ClientEvent>,
|
||||
}
|
||||
|
||||
pub fn session(capacity: usize) -> (ClientPort, ClientSession) {
|
||||
let (commands_tx, commands_rx) = mpsc::channel(capacity);
|
||||
let (events_tx, events_rx) = mpsc::channel(capacity);
|
||||
(
|
||||
ClientPort {
|
||||
commands: commands_rx,
|
||||
events: events_tx,
|
||||
},
|
||||
ClientSession {
|
||||
commands: commands_tx,
|
||||
events: events_rx,
|
||||
},
|
||||
)
|
||||
}
|
||||
+15
-39
@@ -1,4 +1,4 @@
|
||||
use std::{env, net::SocketAddr, str::FromStr, time::Duration};
|
||||
use std::{env, net::SocketAddr, time::Duration};
|
||||
|
||||
use crate::{Error, Result};
|
||||
|
||||
@@ -9,27 +9,13 @@ pub enum ProviderKind {
|
||||
Anthropic,
|
||||
}
|
||||
|
||||
impl FromStr for ProviderKind {
|
||||
type Err = Error;
|
||||
|
||||
fn from_str(value: &str) -> Result<Self> {
|
||||
match value {
|
||||
"openai-chat" => Ok(Self::OpenAiChat),
|
||||
"openai-responses" => Ok(Self::OpenAiResponses),
|
||||
"anthropic" => Ok(Self::Anthropic),
|
||||
other => Err(Error::Config(format!(
|
||||
"unsupported CURSOR_PROVIDER: {other}"
|
||||
))),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ProviderConfig {
|
||||
pub kind: ProviderKind,
|
||||
pub base_url: String,
|
||||
pub api_key: String,
|
||||
pub model: String,
|
||||
pub custom_headers: reqwest::header::HeaderMap,
|
||||
pub max_output_tokens: Option<u64>,
|
||||
pub request_timeout: Duration,
|
||||
}
|
||||
|
||||
@@ -37,7 +23,7 @@ pub struct ProviderConfig {
|
||||
pub struct Config {
|
||||
pub listen_addr: SocketAddr,
|
||||
pub database_url: String,
|
||||
pub provider: ProviderConfig,
|
||||
pub provider_request_timeout: Duration,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
@@ -46,32 +32,22 @@ impl Config {
|
||||
.unwrap_or_else(|_| "127.0.0.1:3000".into())
|
||||
.parse()
|
||||
.map_err(|error| Error::Config(format!("invalid CURSOR_LISTEN_ADDR: {error}")))?;
|
||||
let kind = env::var("CURSOR_PROVIDER")
|
||||
.unwrap_or_else(|_| "openai-chat".into())
|
||||
.parse()?;
|
||||
let default_base = match kind {
|
||||
ProviderKind::Anthropic => "https://api.anthropic.com/v1",
|
||||
_ => "https://api.openai.com/v1",
|
||||
let request_timeout = match env::var("CURSOR_PROVIDER_TIMEOUT_SECONDS") {
|
||||
Ok(value) => Duration::from_secs(value.parse().map_err(|error| {
|
||||
Error::Config(format!("invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"))
|
||||
})?),
|
||||
Err(env::VarError::NotPresent) => Duration::from_secs(300),
|
||||
Err(error) => {
|
||||
return Err(Error::Config(format!(
|
||||
"invalid CURSOR_PROVIDER_TIMEOUT_SECONDS: {error}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(Self {
|
||||
listen_addr,
|
||||
database_url: env::var("CURSOR_DATABASE_URL")
|
||||
.unwrap_or_else(|_| "sqlite://cursor-server.db".into()),
|
||||
provider: ProviderConfig {
|
||||
kind,
|
||||
base_url: env::var("CURSOR_PROVIDER_BASE_URL")
|
||||
.unwrap_or_else(|_| default_base.into())
|
||||
.trim_end_matches('/')
|
||||
.into(),
|
||||
api_key: env::var("CURSOR_PROVIDER_API_KEY").unwrap_or_default(),
|
||||
model: env::var("CURSOR_MODEL").unwrap_or_else(|_| "gpt-5".into()),
|
||||
request_timeout: Duration::from_secs(
|
||||
env::var("CURSOR_PROVIDER_TIMEOUT_SECONDS")
|
||||
.ok()
|
||||
.and_then(|value| value.parse().ok())
|
||||
.unwrap_or(300),
|
||||
),
|
||||
},
|
||||
provider_request_timeout: request_timeout,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
use axum::{
|
||||
extract::{Path, Query, State},
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{model::LlmCallSummary, Result};
|
||||
|
||||
use super::{CallDetail, ControlService};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct CallQuery {
|
||||
#[serde(default = "default_limit")]
|
||||
limit: i64,
|
||||
}
|
||||
|
||||
pub async fn list(
|
||||
State(service): State<ControlService>,
|
||||
Query(query): Query<CallQuery>,
|
||||
) -> Result<Json<Vec<LlmCallSummary>>> {
|
||||
Ok(Json(service.calls(query.limit).await?))
|
||||
}
|
||||
|
||||
pub async fn detail(
|
||||
State(service): State<ControlService>,
|
||||
Path(call_id): Path<String>,
|
||||
) -> Result<Json<CallDetail>> {
|
||||
Ok(Json(service.call(&call_id).await?))
|
||||
}
|
||||
|
||||
fn default_limit() -> i64 {
|
||||
100
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
mod calls;
|
||||
mod models;
|
||||
mod providers;
|
||||
mod service;
|
||||
mod settings;
|
||||
|
||||
use axum::{
|
||||
routing::{delete, get, post, put},
|
||||
Router,
|
||||
};
|
||||
use tower_http::services::ServeDir;
|
||||
|
||||
pub use service::{CallDetail, ControlService, DiscoveredModels, ObservabilitySettings};
|
||||
|
||||
pub fn router(service: ControlService, assets: impl AsRef<std::path::Path>) -> Router {
|
||||
Router::new()
|
||||
.nest_service(
|
||||
"/console",
|
||||
ServeDir::new(assets).append_index_html_on_directories(true),
|
||||
)
|
||||
.route(
|
||||
"/api/providers",
|
||||
get(providers::list).post(providers::create),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/{provider_id}",
|
||||
put(providers::update).delete(providers::remove),
|
||||
)
|
||||
.route(
|
||||
"/api/providers/{provider_id}/models/discover",
|
||||
post(models::discover),
|
||||
)
|
||||
.route("/api/providers/{provider_id}/models", post(models::save))
|
||||
.route("/api/models", get(models::list))
|
||||
.route("/api/models/{model_hash}", delete(models::remove))
|
||||
.route("/api/llm-calls", get(calls::list))
|
||||
.route("/api/llm-calls/{call_id}", get(calls::detail))
|
||||
.route(
|
||||
"/api/settings/observability",
|
||||
get(settings::get).put(settings::update),
|
||||
)
|
||||
.with_state(service)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::{
|
||||
model::{ProviderModel, ProviderModelInput},
|
||||
Result,
|
||||
};
|
||||
|
||||
use super::{ControlService, DiscoveredModels};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct SaveModels {
|
||||
pub models: Vec<ProviderModelInput>,
|
||||
}
|
||||
|
||||
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ProviderModel>>> {
|
||||
Ok(Json(service.models().await?))
|
||||
}
|
||||
|
||||
pub async fn save(
|
||||
State(service): State<ControlService>,
|
||||
Path(provider_id): Path<i64>,
|
||||
Json(input): Json<SaveModels>,
|
||||
) -> Result<(StatusCode, Json<Vec<ProviderModel>>)> {
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(service.save_models(provider_id, &input.models).await?),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(service): State<ControlService>,
|
||||
Path(model_hash): Path<String>,
|
||||
) -> Result<StatusCode> {
|
||||
service.delete_model(&model_hash).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
|
||||
pub async fn discover(
|
||||
State(service): State<ControlService>,
|
||||
Path(provider_id): Path<i64>,
|
||||
) -> Result<Json<DiscoveredModels>> {
|
||||
Ok(Json(service.discover_models(provider_id).await?))
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use axum::{
|
||||
extract::{Path, State},
|
||||
http::StatusCode,
|
||||
Json,
|
||||
};
|
||||
|
||||
use crate::{
|
||||
model::{ProviderEndpoint, ProviderEndpointInput},
|
||||
Result,
|
||||
};
|
||||
|
||||
use super::ControlService;
|
||||
|
||||
pub async fn list(State(service): State<ControlService>) -> Result<Json<Vec<ProviderEndpoint>>> {
|
||||
Ok(Json(service.providers().await?))
|
||||
}
|
||||
|
||||
pub async fn create(
|
||||
State(service): State<ControlService>,
|
||||
Json(input): Json<ProviderEndpointInput>,
|
||||
) -> Result<(StatusCode, Json<ProviderEndpoint>)> {
|
||||
Ok((
|
||||
StatusCode::CREATED,
|
||||
Json(service.create_provider(&input).await?),
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(service): State<ControlService>,
|
||||
Path(provider_id): Path<i64>,
|
||||
Json(input): Json<ProviderEndpointInput>,
|
||||
) -> Result<Json<ProviderEndpoint>> {
|
||||
Ok(Json(
|
||||
service.update_provider(provider_id, &input).await?,
|
||||
))
|
||||
}
|
||||
|
||||
pub async fn remove(
|
||||
State(service): State<ControlService>,
|
||||
Path(provider_id): Path<i64>,
|
||||
) -> Result<StatusCode> {
|
||||
service.delete_provider(provider_id).await?;
|
||||
Ok(StatusCode::NO_CONTENT)
|
||||
}
|
||||
@@ -0,0 +1,239 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use reqwest::header::{HeaderName, HeaderValue};
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
LlmCallRequest, LlmCallResponseChunk, LlmCallSummary, ProviderEndpoint,
|
||||
ProviderEndpointInput, ProviderEndpointSecret, ProviderModel, ProviderModelInput,
|
||||
ProviderType,
|
||||
},
|
||||
store::Store,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ControlService {
|
||||
store: Store,
|
||||
client: reqwest::Client,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct DiscoveredModels {
|
||||
pub models: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Serialize)]
|
||||
pub struct CallDetail {
|
||||
pub call: LlmCallSummary,
|
||||
pub request: Option<LlmCallRequest>,
|
||||
pub response_chunks: Vec<LlmCallResponseChunk>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy, Debug, Deserialize, Serialize)]
|
||||
pub struct ObservabilitySettings {
|
||||
pub detailed: bool,
|
||||
}
|
||||
|
||||
impl ControlService {
|
||||
pub fn new(store: Store) -> Self {
|
||||
Self {
|
||||
store,
|
||||
client: reqwest::Client::new(),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn providers(&self) -> Result<Vec<ProviderEndpoint>> {
|
||||
self.store.providers().await
|
||||
}
|
||||
|
||||
pub async fn create_provider(
|
||||
&self,
|
||||
input: &ProviderEndpointInput,
|
||||
) -> Result<ProviderEndpoint> {
|
||||
self.store.create_provider(input).await
|
||||
}
|
||||
|
||||
pub async fn update_provider(
|
||||
&self,
|
||||
provider_id: i64,
|
||||
input: &ProviderEndpointInput,
|
||||
) -> Result<ProviderEndpoint> {
|
||||
self.store.update_provider(provider_id, input).await
|
||||
}
|
||||
|
||||
pub async fn delete_provider(&self, provider_id: i64) -> Result<()> {
|
||||
self.store.delete_provider(provider_id).await
|
||||
}
|
||||
|
||||
pub async fn models(&self) -> Result<Vec<ProviderModel>> {
|
||||
self.store.provider_models(false).await
|
||||
}
|
||||
|
||||
pub async fn save_models(
|
||||
&self,
|
||||
provider_id: i64,
|
||||
models: &[ProviderModelInput],
|
||||
) -> Result<Vec<ProviderModel>> {
|
||||
let mut saved = Vec::with_capacity(models.len());
|
||||
for model in models {
|
||||
saved.push(self.store.save_provider_model(provider_id, model).await?);
|
||||
}
|
||||
Ok(saved)
|
||||
}
|
||||
|
||||
pub async fn delete_model(&self, model_hash: &str) -> Result<()> {
|
||||
self.store.delete_provider_model(model_hash).await
|
||||
}
|
||||
|
||||
pub async fn discover_models(&self, provider_id: i64) -> Result<DiscoveredModels> {
|
||||
let provider = self
|
||||
.store
|
||||
.provider(provider_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("provider {provider_id}")))?;
|
||||
let mut models = match provider.endpoint.provider_type {
|
||||
ProviderType::OpenAiChat | ProviderType::OpenAiResponses => {
|
||||
openai_models(&self.client, &provider).await?
|
||||
}
|
||||
ProviderType::Anthropic => anthropic_models(&self.client, &provider).await?,
|
||||
};
|
||||
models.sort();
|
||||
models.dedup();
|
||||
Ok(DiscoveredModels { models })
|
||||
}
|
||||
|
||||
pub async fn calls(&self, limit: i64) -> Result<Vec<LlmCallSummary>> {
|
||||
self.store.llm_calls(limit).await
|
||||
}
|
||||
|
||||
pub async fn call(&self, call_id: &str) -> Result<CallDetail> {
|
||||
let call = self
|
||||
.store
|
||||
.llm_call(call_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::RunNotFound(format!("LLM call {call_id}")))?;
|
||||
Ok(CallDetail {
|
||||
request: self.store.llm_call_request(call_id).await?,
|
||||
response_chunks: self.store.llm_call_chunks(call_id).await?,
|
||||
call,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn observability(&self) -> Result<ObservabilitySettings> {
|
||||
Ok(ObservabilitySettings {
|
||||
detailed: self.store.detailed_logging().await?,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn set_observability(
|
||||
&self,
|
||||
settings: ObservabilitySettings,
|
||||
) -> Result<ObservabilitySettings> {
|
||||
self.store.set_detailed_logging(settings.detailed).await?;
|
||||
Ok(settings)
|
||||
}
|
||||
}
|
||||
|
||||
async fn openai_models(
|
||||
client: &reqwest::Client,
|
||||
provider: &ProviderEndpointSecret,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut request = client.get(format!("{}/models", provider.endpoint.base_url));
|
||||
if !provider.api_key.is_empty() {
|
||||
request = request.bearer_auth(&provider.api_key);
|
||||
}
|
||||
let response = apply_custom_headers(request, &provider.custom_headers)?
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
if !status.is_success() {
|
||||
return Err(Error::Provider(format!(
|
||||
"model discovery failed ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
Ok(model_ids(body.get("data").unwrap_or(&body)))
|
||||
}
|
||||
|
||||
async fn anthropic_models(
|
||||
client: &reqwest::Client,
|
||||
provider: &ProviderEndpointSecret,
|
||||
) -> Result<Vec<String>> {
|
||||
let mut after_id = None::<String>;
|
||||
let mut found = BTreeSet::new();
|
||||
loop {
|
||||
let mut request = client
|
||||
.get(format!("{}/models", provider.endpoint.base_url))
|
||||
.query(&[("limit", "100")])
|
||||
.header("anthropic-version", "2023-06-01");
|
||||
if !provider.api_key.is_empty() {
|
||||
request = request.header("x-api-key", &provider.api_key);
|
||||
}
|
||||
if let Some(after_id) = &after_id {
|
||||
request = request.query(&[("after_id", after_id)]);
|
||||
}
|
||||
let response = apply_custom_headers(request, &provider.custom_headers)?
|
||||
.send()
|
||||
.await?;
|
||||
let status = response.status();
|
||||
let body: serde_json::Value = response.json().await?;
|
||||
if !status.is_success() {
|
||||
return Err(Error::Provider(format!(
|
||||
"model discovery failed ({status}): {body}"
|
||||
)));
|
||||
}
|
||||
found.extend(model_ids(body.get("data").unwrap_or(&body)));
|
||||
if body.get("has_more").and_then(serde_json::Value::as_bool) != Some(true) {
|
||||
break;
|
||||
}
|
||||
after_id = body
|
||||
.get("last_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned);
|
||||
if after_id.is_none() {
|
||||
return Err(Error::Provider(
|
||||
"Anthropic model response has_more without last_id".into(),
|
||||
));
|
||||
}
|
||||
}
|
||||
Ok(found.into_iter().collect())
|
||||
}
|
||||
|
||||
fn model_ids(value: &serde_json::Value) -> Vec<String> {
|
||||
value
|
||||
.as_array()
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(|item| match item {
|
||||
serde_json::Value::String(id) => Some(id.clone()),
|
||||
serde_json::Value::Object(object) => object
|
||||
.get("id")
|
||||
.or_else(|| object.get("name"))
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.map(str::to_owned),
|
||||
_ => None,
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn apply_custom_headers(
|
||||
mut request: reqwest::RequestBuilder,
|
||||
headers: &serde_json::Value,
|
||||
) -> Result<reqwest::RequestBuilder> {
|
||||
let object = headers
|
||||
.as_object()
|
||||
.ok_or_else(|| Error::Config("custom headers must be an object".into()))?;
|
||||
for (name, value) in object {
|
||||
let value = value
|
||||
.as_str()
|
||||
.ok_or_else(|| Error::Config(format!("custom header {name} must be a string")))?;
|
||||
let name = HeaderName::try_from(name)
|
||||
.map_err(|error| Error::Config(format!("invalid header name: {error}")))?;
|
||||
let value = HeaderValue::try_from(value)
|
||||
.map_err(|error| Error::Config(format!("invalid header value: {error}")))?;
|
||||
request = request.header(name, value);
|
||||
}
|
||||
Ok(request)
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
use axum::{extract::State, Json};
|
||||
use crate::Result;
|
||||
|
||||
use super::{ControlService, ObservabilitySettings};
|
||||
|
||||
pub async fn get(State(service): State<ControlService>) -> Result<Json<ObservabilitySettings>> {
|
||||
Ok(Json(service.observability().await?))
|
||||
}
|
||||
|
||||
pub async fn update(
|
||||
State(service): State<ControlService>,
|
||||
Json(settings): Json<ObservabilitySettings>,
|
||||
) -> Result<Json<ObservabilitySettings>> {
|
||||
Ok(Json(service.set_observability(settings).await?))
|
||||
}
|
||||
@@ -0,0 +1,246 @@
|
||||
use std::sync::Arc;
|
||||
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{
|
||||
cursor::prompting::PromptCompiler,
|
||||
cursor::{
|
||||
blob_sync::BlobSynchronizer,
|
||||
checkpoint::CheckpointBuilder,
|
||||
proto::agent::v1 as pb,
|
||||
request,
|
||||
session::CursorSession,
|
||||
tools::{
|
||||
codec, result::tool_result_channel, runtime::CursorToolRuntime, ClientToolEvent,
|
||||
ToolDispatcher,
|
||||
},
|
||||
},
|
||||
provider::Provider,
|
||||
run::{RunActor, RunRegistry},
|
||||
store::Store,
|
||||
};
|
||||
|
||||
use super::{inbox::OrderedInbox, CursorCommand, CursorSessionHandle};
|
||||
|
||||
pub struct CursorActor;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(crate) struct RunDependencies {
|
||||
pub store: Store,
|
||||
pub provider: Arc<dyn Provider>,
|
||||
pub compiler: PromptCompiler,
|
||||
pub run_registry: RunRegistry,
|
||||
}
|
||||
|
||||
impl CursorActor {
|
||||
pub(crate) fn spawn(
|
||||
handle: CursorSessionHandle,
|
||||
mut receiver: mpsc::Receiver<CursorCommand>,
|
||||
dependencies: RunDependencies,
|
||||
blob_sync: BlobSynchronizer,
|
||||
next_append_seqno: i64,
|
||||
) {
|
||||
tokio::spawn(async move {
|
||||
let mut inbox = OrderedInbox::starting_at(next_append_seqno);
|
||||
let (results_tx, results_rx) = tool_result_channel();
|
||||
let tool_runtime = CursorToolRuntime::default();
|
||||
let tools = ToolDispatcher::with_results(tool_runtime.clone(), results_tx.clone());
|
||||
let mut run_resources = Some((results_rx, dependencies));
|
||||
loop {
|
||||
let command = match receiver.recv().await {
|
||||
Some(command) => command,
|
||||
None => {
|
||||
handle.cancel();
|
||||
break;
|
||||
}
|
||||
};
|
||||
match command {
|
||||
CursorCommand::Abort => {
|
||||
handle.cancel();
|
||||
}
|
||||
CursorCommand::Finished => {
|
||||
break;
|
||||
}
|
||||
CursorCommand::Append { seqno, message } => {
|
||||
for (_seqno, message) in inbox.push(seqno, *message) {
|
||||
{
|
||||
match message.message {
|
||||
Some(pb::agent_client_message::Message::RunRequest(
|
||||
request,
|
||||
)) => {
|
||||
if let Some((results, dependencies)) = run_resources.take()
|
||||
{
|
||||
let handle = handle.clone();
|
||||
let blob_sync = blob_sync.clone();
|
||||
let tools = tools.clone();
|
||||
let tool_runtime = tool_runtime.clone();
|
||||
tokio::spawn(async move {
|
||||
let mut checkpoint = CheckpointBuilder::new(
|
||||
dependencies.store.clone(),
|
||||
blob_sync.clone(),
|
||||
handle
|
||||
.parent()
|
||||
.map(|parent| parent.tool_call_id.clone()),
|
||||
request.conversation_state.clone(),
|
||||
);
|
||||
let parent = handle.parent().map(|parent| {
|
||||
(
|
||||
crate::model::RunId::new(&parent.run_id),
|
||||
parent.tool_call_id.clone(),
|
||||
)
|
||||
});
|
||||
let prepared = request::prepare(
|
||||
handle.request_id(),
|
||||
&request,
|
||||
parent,
|
||||
request::PrepareDependencies {
|
||||
compiler: &dependencies.compiler,
|
||||
store: &dependencies.store,
|
||||
checkpoint: &checkpoint,
|
||||
blob_sync: &blob_sync,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
let (prepared, context) = match prepared {
|
||||
Ok(prepared) => prepared,
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
request_id = handle.request_id(),
|
||||
%error,
|
||||
"failed to prepare Cursor Run"
|
||||
);
|
||||
let _ = crate::cursor::lifecycle::fail(
|
||||
&handle, &error,
|
||||
);
|
||||
let _ = handle
|
||||
.command(CursorCommand::Finished)
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
};
|
||||
checkpoint.configure(
|
||||
prepared.model.model_id.clone(),
|
||||
prepared.model.context_window_tokens,
|
||||
prepared.prompt.instructions.clone(),
|
||||
prepared.prompt.tools.clone(),
|
||||
context.dynamic_tools.keys().cloned().collect(),
|
||||
context.turn_user.clone(),
|
||||
);
|
||||
let cancellation = handle.cancellation();
|
||||
let (port, core) = crate::client::session(256);
|
||||
let actor = RunActor::new(
|
||||
dependencies.store.clone(),
|
||||
dependencies.provider,
|
||||
dependencies.run_registry,
|
||||
);
|
||||
let core_run =
|
||||
actor.spawn(prepared, port, cancellation).await;
|
||||
let session = CursorSession::new(
|
||||
handle.clone(),
|
||||
dependencies.store,
|
||||
context,
|
||||
core,
|
||||
super::session::CursorSessionRuntime {
|
||||
tools,
|
||||
results,
|
||||
checkpoint,
|
||||
tool_runtime,
|
||||
},
|
||||
);
|
||||
if let Err(error) = session.run().await {
|
||||
tracing::error!(
|
||||
request_id = handle.request_id(),
|
||||
%error,
|
||||
"Cursor session failed"
|
||||
);
|
||||
handle.cancel();
|
||||
let _ = crate::cursor::lifecycle::fail(
|
||||
&handle, &error,
|
||||
);
|
||||
}
|
||||
let _ = core_run.await;
|
||||
let _ =
|
||||
handle.command(CursorCommand::Finished).await;
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(pb::agent_client_message::Message::ExecClientMessage(
|
||||
message,
|
||||
)) => {
|
||||
match codec::client_event(&message, &tool_runtime).await {
|
||||
Ok(codec::ClientExecEvent::Delta(message)) => {
|
||||
let _ = handle.emit(&message);
|
||||
}
|
||||
Ok(codec::ClientExecEvent::Message(message)) => {
|
||||
let _ = handle.emit(&message);
|
||||
}
|
||||
Ok(codec::ClientExecEvent::Completed(result)) => {
|
||||
results_tx.send(*result)
|
||||
}
|
||||
Ok(codec::ClientExecEvent::Pending) => {}
|
||||
Err(error) => results_tx.send_error(error),
|
||||
}
|
||||
}
|
||||
Some(
|
||||
pb::agent_client_message::Message::ExecClientControlMessage(
|
||||
message,
|
||||
),
|
||||
) => {
|
||||
use pb::exec_client_control_message::Message;
|
||||
match message.message {
|
||||
Some(Message::StreamClose(close)) => {
|
||||
if tool_runtime.take_exec(close.id).await.is_some()
|
||||
{
|
||||
results_tx.send_error(crate::Error::Protocol(format!(
|
||||
"Exec stream closed before result for id: {}",
|
||||
close.id
|
||||
)));
|
||||
}
|
||||
}
|
||||
Some(Message::Throw(throw)) => {
|
||||
match tool_runtime.take_exec(throw.id).await {
|
||||
Some(pending) => results_tx.send_error(
|
||||
crate::Error::Protocol(format!(
|
||||
"Exec {} failed: {}",
|
||||
pending.call.call_id, throw.error
|
||||
)),
|
||||
),
|
||||
None => results_tx.send_error(
|
||||
crate::Error::Protocol(format!(
|
||||
"unknown ExecClientThrow id: {}",
|
||||
throw.id
|
||||
)),
|
||||
),
|
||||
}
|
||||
}
|
||||
Some(Message::Heartbeat(_)) | None => {}
|
||||
}
|
||||
}
|
||||
Some(
|
||||
pb::agent_client_message::Message::InteractionResponse(
|
||||
message,
|
||||
),
|
||||
) => match tools.interaction_response(&message).await {
|
||||
Ok(ClientToolEvent::Message(message)) => {
|
||||
let _ = handle.emit(&message);
|
||||
}
|
||||
Ok(ClientToolEvent::Completed(completion)) => {
|
||||
results_tx.send(*completion)
|
||||
}
|
||||
Err(error) => results_tx.send_error(error),
|
||||
},
|
||||
Some(pb::agent_client_message::Message::KvClientMessage(
|
||||
message,
|
||||
)) => {
|
||||
let _ = blob_sync.handle_client(message).await;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -2,13 +2,14 @@ use prost::Message;
|
||||
|
||||
use crate::{
|
||||
cursor::proto::{agent::v1 as agent, aiserver::v1 as ai},
|
||||
run::{RunCommand, RunRegistry},
|
||||
cursor::{CursorCommand, CursorParent, CursorSessionRegistry},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
pub async fn append(
|
||||
registry: &RunRegistry,
|
||||
registry: &CursorSessionRegistry,
|
||||
request: ai::BidiAppendRequest,
|
||||
parent: Option<CursorParent>,
|
||||
) -> Result<ai::BidiAppendResponse> {
|
||||
let request_id = request
|
||||
.request_id
|
||||
@@ -29,17 +30,12 @@ pub async fn append(
|
||||
let payload = hex::decode(&request.data)
|
||||
.map_err(|error| Error::Protocol(format!("invalid BidiAppend hex: {error}")))?;
|
||||
let message = agent::AgentClientMessage::decode(payload.as_slice())?;
|
||||
if let Some(agent::agent_client_message::Message::RunRequest(run)) = &message.message {
|
||||
if let Some(conversation_id) = run.conversation_id.as_deref() {
|
||||
registry
|
||||
.bind_conversation(conversation_id, request_id)
|
||||
.await;
|
||||
}
|
||||
let handle = registry.get_or_create(request_id).await?;
|
||||
if let Some(parent) = parent {
|
||||
handle.set_parent(parent)?;
|
||||
}
|
||||
registry
|
||||
.get_or_create(request_id)
|
||||
.await?
|
||||
.command(RunCommand::Append {
|
||||
handle
|
||||
.command(CursorCommand::Append {
|
||||
seqno: request.append_seqno,
|
||||
message: Box::new(message),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
collections::{HashMap, HashSet},
|
||||
sync::{
|
||||
atomic::{AtomicU32, Ordering},
|
||||
Arc,
|
||||
@@ -7,17 +7,16 @@ use std::{
|
||||
time::Duration,
|
||||
};
|
||||
|
||||
use prost::Message;
|
||||
use tokio::sync::{oneshot, Mutex, Notify};
|
||||
use tokio::sync::{oneshot, Mutex};
|
||||
|
||||
use crate::{
|
||||
cursor::proto::agent::v1 as pb,
|
||||
run::RunHandle,
|
||||
cursor::CursorSessionHandle,
|
||||
store::{BlobEdge, BlobId, Store},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
type BlobGetSender = oneshot::Sender<Result<Option<Vec<u8>>>>;
|
||||
type BlobSetSender = oneshot::Sender<Result<()>>;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct BlobSynchronizer {
|
||||
@@ -27,15 +26,26 @@ pub struct BlobSynchronizer {
|
||||
struct Inner {
|
||||
request_id: String,
|
||||
store: Store,
|
||||
handle: RunHandle,
|
||||
handle: CursorSessionHandle,
|
||||
next_id: AtomicU32,
|
||||
set_requests: Mutex<HashMap<u32, BlobId>>,
|
||||
get_requests: Mutex<HashMap<u32, BlobGetSender>>,
|
||||
ack: Notify,
|
||||
set_requests: Mutex<HashMap<u32, PendingSet>>,
|
||||
acked_blobs: Mutex<HashSet<BlobId>>,
|
||||
get_requests: Mutex<HashMap<u32, PendingGet>>,
|
||||
}
|
||||
|
||||
struct PendingSet {
|
||||
blob_id: BlobId,
|
||||
sent_at: std::time::Instant,
|
||||
result: BlobSetSender,
|
||||
}
|
||||
|
||||
struct PendingGet {
|
||||
blob_id: BlobId,
|
||||
result: oneshot::Sender<Result<Option<Vec<u8>>>>,
|
||||
}
|
||||
|
||||
impl BlobSynchronizer {
|
||||
pub fn new(request_id: String, store: Store, handle: RunHandle) -> Self {
|
||||
pub fn new(request_id: String, store: Store, handle: CursorSessionHandle) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(Inner {
|
||||
request_id,
|
||||
@@ -43,8 +53,8 @@ impl BlobSynchronizer {
|
||||
handle,
|
||||
next_id: AtomicU32::new(1),
|
||||
set_requests: Mutex::new(HashMap::new()),
|
||||
acked_blobs: Mutex::new(HashSet::new()),
|
||||
get_requests: Mutex::new(HashMap::new()),
|
||||
ack: Notify::new(),
|
||||
}),
|
||||
}
|
||||
}
|
||||
@@ -53,97 +63,54 @@ impl BlobSynchronizer {
|
||||
&self.inner.request_id
|
||||
}
|
||||
|
||||
pub async fn recover(&self) -> Result<()> {
|
||||
for item in self
|
||||
.inner
|
||||
.store
|
||||
.pending_outbox(&self.inner.request_id)
|
||||
.await?
|
||||
{
|
||||
if item.kind != "kv_set" {
|
||||
continue;
|
||||
}
|
||||
let encoded = item
|
||||
.key
|
||||
.strip_prefix("blob:")
|
||||
.ok_or_else(|| Error::Protocol(format!("invalid Blob outbox key: {}", item.key)))?;
|
||||
let blob_id = BlobId::from_base64(encoded)?;
|
||||
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
self.inner
|
||||
.set_requests
|
||||
.lock()
|
||||
.await
|
||||
.insert(id, blob_id.clone());
|
||||
self.inner.handle.emit(&pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::KvServerMessage(
|
||||
pb::KvServerMessage {
|
||||
id,
|
||||
span_context: None,
|
||||
message: Some(pb::kv_server_message::Message::SetBlobArgs(
|
||||
pb::SetBlobArgs {
|
||||
blob_id: blob_id.as_bytes().to_vec(),
|
||||
blob_data: item.payload,
|
||||
},
|
||||
)),
|
||||
},
|
||||
)),
|
||||
})?;
|
||||
self.inner.store.mark_outbox_sent(item.id).await?;
|
||||
}
|
||||
self.publish_ready_checkpoints().await
|
||||
}
|
||||
|
||||
pub async fn persist(&self, data: &[u8], edges: &[BlobEdge]) -> Result<BlobId> {
|
||||
let id = self.inner.store.put_blob(data, edges).await?;
|
||||
let key = format!("blob:{}", id.to_base64());
|
||||
self.inner
|
||||
.store
|
||||
.enqueue_outbox(&self.inner.request_id, &key, "kv_set", data, &[])
|
||||
.await?;
|
||||
self.ensure_set(&id, data).await?;
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
async fn ensure_set(&self, blob_id: &BlobId, data: &[u8]) -> Result<()> {
|
||||
let dependency = [blob_id.clone()];
|
||||
loop {
|
||||
if self
|
||||
.inner
|
||||
.store
|
||||
.dependencies_acked(&self.inner.request_id, &dependency)
|
||||
.await?
|
||||
{
|
||||
return Ok(());
|
||||
}
|
||||
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
self.inner
|
||||
.set_requests
|
||||
.lock()
|
||||
.await
|
||||
.insert(id, blob_id.clone());
|
||||
self.inner.handle.emit(&pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::KvServerMessage(
|
||||
pb::KvServerMessage {
|
||||
id,
|
||||
span_context: None,
|
||||
message: Some(pb::kv_server_message::Message::SetBlobArgs(
|
||||
pb::SetBlobArgs {
|
||||
blob_id: blob_id.as_bytes().to_vec(),
|
||||
blob_data: data.to_vec(),
|
||||
},
|
||||
)),
|
||||
},
|
||||
)),
|
||||
})?;
|
||||
let cancellation = self.inner.handle.cancellation();
|
||||
tokio::select! {
|
||||
_ = self.inner.ack.notified() => {}
|
||||
_ = tokio::time::sleep(Duration::from_secs(2)) => {}
|
||||
_ = cancellation.cancelled() => return Err(Error::Cancelled),
|
||||
}
|
||||
if self.inner.acked_blobs.lock().await.contains(blob_id) {
|
||||
return Ok(());
|
||||
}
|
||||
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
self.inner.set_requests.lock().await.insert(
|
||||
id,
|
||||
PendingSet {
|
||||
blob_id: blob_id.clone(),
|
||||
sent_at: std::time::Instant::now(),
|
||||
result: sender,
|
||||
},
|
||||
);
|
||||
if let Err(error) = self.inner.handle.emit(&pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::KvServerMessage(
|
||||
pb::KvServerMessage {
|
||||
id,
|
||||
span_context: None,
|
||||
message: Some(pb::kv_server_message::Message::SetBlobArgs(
|
||||
pb::SetBlobArgs {
|
||||
blob_id: blob_id.as_bytes().to_vec(),
|
||||
blob_data: data.to_vec(),
|
||||
},
|
||||
)),
|
||||
},
|
||||
)),
|
||||
}) {
|
||||
self.inner.set_requests.lock().await.remove(&id);
|
||||
return Err(error);
|
||||
}
|
||||
let cancellation = self.inner.handle.cancellation();
|
||||
let result = tokio::select! {
|
||||
result = receiver => result.map_err(|_| Error::Protocol("KV SET response channel closed".into()))?,
|
||||
_ = cancellation.cancelled() => Err(Error::Cancelled),
|
||||
_ = tokio::time::sleep(Duration::from_secs(15)) => Err(Error::Protocol(format!("KV SET timed out: {}", blob_id.to_base64()))),
|
||||
};
|
||||
if result.is_err() {
|
||||
self.inner.set_requests.lock().await.remove(&id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn get(&self, blob_id: &BlobId) -> Result<Option<Vec<u8>>> {
|
||||
@@ -152,7 +119,13 @@ impl BlobSynchronizer {
|
||||
}
|
||||
let id = self.inner.next_id.fetch_add(1, Ordering::Relaxed);
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
self.inner.get_requests.lock().await.insert(id, sender);
|
||||
self.inner.get_requests.lock().await.insert(
|
||||
id,
|
||||
PendingGet {
|
||||
blob_id: blob_id.clone(),
|
||||
result: sender,
|
||||
},
|
||||
);
|
||||
self.inner.handle.emit(&pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::KvServerMessage(
|
||||
@@ -168,81 +141,96 @@ impl BlobSynchronizer {
|
||||
)),
|
||||
})?;
|
||||
let cancellation = self.inner.handle.cancellation();
|
||||
tokio::select! {
|
||||
let result = tokio::select! {
|
||||
result = receiver => result.map_err(|_| Error::Protocol("KV GET response channel closed".into()))?,
|
||||
_ = cancellation.cancelled() => Err(Error::Cancelled),
|
||||
_ = tokio::time::sleep(Duration::from_secs(15)) => Err(Error::Protocol(format!("KV GET timed out: {}", blob_id.to_base64()))),
|
||||
};
|
||||
if result.is_err() {
|
||||
self.inner.get_requests.lock().await.remove(&id);
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
pub async fn cache_received(&self, blob_id: &BlobId, data: &[u8]) -> Result<()> {
|
||||
let actual = BlobId::digest(data);
|
||||
if actual != *blob_id {
|
||||
return Err(Error::Protocol(format!(
|
||||
"received Blob hash mismatch: expected {}, got {}",
|
||||
blob_id.to_base64(),
|
||||
actual.to_base64()
|
||||
)));
|
||||
}
|
||||
self.inner.store.put_blob(data, &[]).await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn handle_client(&self, message: pb::KvClientMessage) -> Result<()> {
|
||||
match message.message {
|
||||
Some(pb::kv_client_message::Message::SetBlobResult(result)) => {
|
||||
if result.error.is_none() {
|
||||
if let Some(blob_id) = self.inner.set_requests.lock().await.remove(&message.id)
|
||||
{
|
||||
self.inner
|
||||
.store
|
||||
.ack_outbox(
|
||||
&self.inner.request_id,
|
||||
&format!("blob:{}", blob_id.to_base64()),
|
||||
)
|
||||
.await?;
|
||||
self.inner.ack.notify_waiters();
|
||||
if let Some(pending) = self.inner.set_requests.lock().await.remove(&message.id) {
|
||||
if let Some(error) = result.error {
|
||||
tracing::error!(
|
||||
request_id = self.request_id(),
|
||||
kv_id = message.id,
|
||||
blob_id = pending.blob_id.to_base64(),
|
||||
error = error.message,
|
||||
"Cursor rejected Blob SET"
|
||||
);
|
||||
let _ = pending.result.send(Err(Error::Protocol(format!(
|
||||
"KV SET {}: {}",
|
||||
pending.blob_id.to_base64(),
|
||||
error.message
|
||||
))));
|
||||
} else {
|
||||
tracing::debug!(
|
||||
request_id = self.request_id(),
|
||||
kv_id = message.id,
|
||||
blob_id = pending.blob_id.to_base64(),
|
||||
elapsed_ms = pending.sent_at.elapsed().as_millis(),
|
||||
"Cursor acknowledged Blob SET"
|
||||
);
|
||||
self.inner.acked_blobs.lock().await.insert(pending.blob_id);
|
||||
let _ = pending.result.send(Ok(()));
|
||||
}
|
||||
} else {
|
||||
tracing::warn!(
|
||||
request_id = self.request_id(),
|
||||
kv_id = message.id,
|
||||
"unknown Cursor Blob SET acknowledgement"
|
||||
);
|
||||
}
|
||||
}
|
||||
Some(pb::kv_client_message::Message::GetBlobResult(result)) => {
|
||||
if let Some(sender) = self.inner.get_requests.lock().await.remove(&message.id) {
|
||||
if let Some(pending) = self.inner.get_requests.lock().await.remove(&message.id) {
|
||||
let value = if let Some(error) = result.error {
|
||||
Err(Error::Protocol(format!("KV GET: {}", error.message)))
|
||||
} else if let Some(data) = result.blob_data {
|
||||
let actual = BlobId::digest(&data);
|
||||
if actual != pending.blob_id {
|
||||
Err(Error::Protocol(format!(
|
||||
"KV GET Blob hash mismatch: expected {}, got {}",
|
||||
pending.blob_id.to_base64(),
|
||||
actual.to_base64()
|
||||
)))
|
||||
} else {
|
||||
self.inner.store.put_blob(&data, &[]).await?;
|
||||
Ok(Some(data))
|
||||
}
|
||||
} else {
|
||||
Ok(result.blob_data)
|
||||
Ok(None)
|
||||
};
|
||||
let _ = sender.send(value);
|
||||
let _ = pending.result.send(value);
|
||||
} else {
|
||||
tracing::warn!(
|
||||
request_id = self.request_id(),
|
||||
kv_id = message.id,
|
||||
"unknown Cursor Blob GET response"
|
||||
);
|
||||
}
|
||||
}
|
||||
None => {}
|
||||
}
|
||||
self.publish_ready_checkpoints().await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn publish_ready_checkpoints(&self) -> Result<()> {
|
||||
for item in self
|
||||
.inner
|
||||
.store
|
||||
.pending_outbox(&self.inner.request_id)
|
||||
.await?
|
||||
{
|
||||
if item.kind != "checkpoint" {
|
||||
continue;
|
||||
}
|
||||
let dependencies = item
|
||||
.dependencies
|
||||
.iter()
|
||||
.map(|id| BlobId::from_base64(id))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if !self
|
||||
.inner
|
||||
.store
|
||||
.dependencies_acked(&self.inner.request_id, &dependencies)
|
||||
.await?
|
||||
{
|
||||
continue;
|
||||
}
|
||||
let checkpoint = pb::ConversationStateStructure::decode(item.payload.as_slice())?;
|
||||
self.inner.handle.emit(&pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(
|
||||
pb::agent_server_message::Message::ConversationCheckpointUpdate(checkpoint),
|
||||
),
|
||||
})?;
|
||||
self.inner
|
||||
.store
|
||||
.ack_outbox(&self.inner.request_id, &item.key)
|
||||
.await?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,384 +0,0 @@
|
||||
use prost::Message;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{
|
||||
cursor::{blob_sync::BlobSynchronizer, interaction::render_tool_call, proto::agent::v1 as pb},
|
||||
model::{CanonicalMessage, MessageContent, Origin, Role, ToolCall},
|
||||
prompting::fold_derived_state,
|
||||
run::RunHandle,
|
||||
store::{BlobEdge, BlobId, Store},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
pub struct CheckpointBuilder {
|
||||
store: Store,
|
||||
sync: BlobSynchronizer,
|
||||
}
|
||||
|
||||
impl CheckpointBuilder {
|
||||
pub fn new(store: Store, sync: BlobSynchronizer) -> Self {
|
||||
Self { store, sync }
|
||||
}
|
||||
|
||||
pub async fn build(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
revision: i64,
|
||||
messages: &[CanonicalMessage],
|
||||
mode: i32,
|
||||
) -> Result<pb::ConversationStateStructure> {
|
||||
self.build_with_tool_progress(
|
||||
conversation_id,
|
||||
revision,
|
||||
messages,
|
||||
mode,
|
||||
&[],
|
||||
&HashSet::new(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn build_with_tool_progress(
|
||||
&self,
|
||||
conversation_id: &str,
|
||||
revision: i64,
|
||||
messages: &[CanonicalMessage],
|
||||
mode: i32,
|
||||
active_calls: &[ToolCall],
|
||||
completed: &HashSet<String>,
|
||||
) -> Result<pb::ConversationStateStructure> {
|
||||
let mut root_ids = Vec::with_capacity(messages.len());
|
||||
for message in messages {
|
||||
root_ids.push(
|
||||
self.sync
|
||||
.persist(&serde_json::to_vec(message)?, &[])
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
let turn_ids = self.build_turns(messages, mode, completed).await?;
|
||||
let (todo_ids, plan_id) = self.build_derived_state(messages).await?;
|
||||
let checkpoint = pb::ConversationStateStructure {
|
||||
root_prompt_messages_json: root_ids.iter().map(|id| id.as_bytes().to_vec()).collect(),
|
||||
turns: turn_ids.iter().map(|id| id.as_bytes().to_vec()).collect(),
|
||||
todos: todo_ids.iter().map(|id| id.as_bytes().to_vec()).collect(),
|
||||
plan: plan_id.as_ref().map(|id| id.as_bytes().to_vec()),
|
||||
pending_tool_calls: active_calls
|
||||
.iter()
|
||||
.filter(|call| !completed.contains(&call.call_id))
|
||||
.map(|call| call.call_id.clone())
|
||||
.collect(),
|
||||
mode: Some(mode),
|
||||
..Default::default()
|
||||
};
|
||||
let mut encoded = Vec::new();
|
||||
checkpoint.encode(&mut encoded)?;
|
||||
let mut edges = root_ids
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, child)| BlobEdge {
|
||||
child: child.clone(),
|
||||
field_name: format!("root_prompt_messages_json[{index}]"),
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
edges.extend(turn_ids.iter().enumerate().map(|(index, child)| BlobEdge {
|
||||
child: child.clone(),
|
||||
field_name: format!("turns[{index}]"),
|
||||
}));
|
||||
edges.extend(todo_ids.iter().enumerate().map(|(index, child)| BlobEdge {
|
||||
child: child.clone(),
|
||||
field_name: format!("todos[{index}]"),
|
||||
}));
|
||||
if let Some(child) = plan_id {
|
||||
edges.push(BlobEdge {
|
||||
child,
|
||||
field_name: "plan".into(),
|
||||
});
|
||||
}
|
||||
let head = self.sync.persist(&encoded, &edges).await?;
|
||||
if !self
|
||||
.store
|
||||
.publish_head(conversation_id, revision, &head)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::Cancelled);
|
||||
}
|
||||
let dependencies = self.store.blob_closure(std::slice::from_ref(&head)).await?;
|
||||
self.store
|
||||
.enqueue_outbox(
|
||||
self.sync.request_id(),
|
||||
&format!("checkpoint:{}", head.to_base64()),
|
||||
"checkpoint",
|
||||
&encoded,
|
||||
&dependencies,
|
||||
)
|
||||
.await?;
|
||||
Ok(checkpoint)
|
||||
}
|
||||
|
||||
pub async fn publish(
|
||||
&self,
|
||||
handle: &RunHandle,
|
||||
checkpoint: &pb::ConversationStateStructure,
|
||||
) -> Result<()> {
|
||||
let encoded = checkpoint.encode_to_vec();
|
||||
let head = BlobId::digest(&encoded);
|
||||
let dependencies = self.store.blob_closure(std::slice::from_ref(&head)).await?;
|
||||
if !self
|
||||
.store
|
||||
.dependencies_acked(self.sync.request_id(), &dependencies)
|
||||
.await?
|
||||
{
|
||||
return Err(Error::Protocol(format!(
|
||||
"checkpoint {} published before Blob ACK barrier",
|
||||
head.to_base64()
|
||||
)));
|
||||
}
|
||||
handle.emit(&pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(
|
||||
pb::agent_server_message::Message::ConversationCheckpointUpdate(checkpoint.clone()),
|
||||
),
|
||||
})?;
|
||||
self.store
|
||||
.ack_outbox(
|
||||
self.sync.request_id(),
|
||||
&format!("checkpoint:{}", head.to_base64()),
|
||||
)
|
||||
.await?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn build_derived_state(
|
||||
&self,
|
||||
messages: &[CanonicalMessage],
|
||||
) -> Result<(Vec<BlobId>, Option<BlobId>)> {
|
||||
let state = fold_derived_state(messages);
|
||||
let todo_values = state
|
||||
.todos
|
||||
.as_ref()
|
||||
.and_then(|value| value.get("todos").or(Some(value)))
|
||||
.and_then(serde_json::Value::as_array);
|
||||
let mut todo_ids = Vec::new();
|
||||
for todo in todo_values.into_iter().flatten() {
|
||||
let status = match todo
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("pending")
|
||||
{
|
||||
"in_progress" => pb::TodoStatus::InProgress,
|
||||
"completed" => pb::TodoStatus::Completed,
|
||||
"cancelled" => pb::TodoStatus::Cancelled,
|
||||
_ => pb::TodoStatus::Pending,
|
||||
};
|
||||
let message = pb::TodoItem {
|
||||
id: todo
|
||||
.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
content: todo
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
status: status as i32,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
dependencies: todo
|
||||
.get("dependencies")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
};
|
||||
let mut encoded = Vec::new();
|
||||
message.encode(&mut encoded)?;
|
||||
todo_ids.push(self.sync.persist(&encoded, &[]).await?);
|
||||
}
|
||||
let plan_id = if let Some(value) = state.plan {
|
||||
let text = value
|
||||
.get("plan")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or_else(|| value.as_str())
|
||||
.unwrap_or_else(|| {
|
||||
value
|
||||
.get("overview")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or_default()
|
||||
});
|
||||
let mut encoded = Vec::new();
|
||||
pb::ConversationPlan { plan: text.into() }.encode(&mut encoded)?;
|
||||
Some(self.sync.persist(&encoded, &[]).await?)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((todo_ids, plan_id))
|
||||
}
|
||||
|
||||
async fn build_turns(
|
||||
&self,
|
||||
messages: &[CanonicalMessage],
|
||||
mode: i32,
|
||||
completed_overlay: &HashSet<String>,
|
||||
) -> Result<Vec<BlobId>> {
|
||||
let mut completed = completed_overlay.clone();
|
||||
for message in messages {
|
||||
if let MessageContent::ToolResult(result) = &message.content {
|
||||
completed.insert(result.call_id.clone());
|
||||
}
|
||||
}
|
||||
let mut turns = Vec::<(CanonicalMessage, Vec<&CanonicalMessage>)>::new();
|
||||
for message in messages {
|
||||
if message.role == Role::User && message.origin == Origin::User {
|
||||
turns.push((message.clone(), Vec::new()));
|
||||
} else if matches!(message.origin, Origin::Assistant | Origin::Tool) {
|
||||
if let Some((_, steps)) = turns.last_mut() {
|
||||
steps.push(message);
|
||||
}
|
||||
}
|
||||
}
|
||||
let mut turn_ids = Vec::with_capacity(turns.len());
|
||||
for (user, step_messages) in turns {
|
||||
let text = match user.content {
|
||||
MessageContent::Text { text } => text,
|
||||
other => serde_json::to_string(&other)?,
|
||||
};
|
||||
let user_message = pb::UserMessage {
|
||||
text,
|
||||
message_id: user.message_id.clone(),
|
||||
mode,
|
||||
..Default::default()
|
||||
};
|
||||
let mut encoded = Vec::new();
|
||||
user_message.encode(&mut encoded)?;
|
||||
let user_id = self.sync.persist(&encoded, &[]).await?;
|
||||
let mut step_ids = Vec::new();
|
||||
for message in step_messages {
|
||||
for step in message_steps(message, &completed)? {
|
||||
let mut encoded = Vec::new();
|
||||
step.encode(&mut encoded)?;
|
||||
step_ids.push(self.sync.persist(&encoded, &[]).await?);
|
||||
}
|
||||
}
|
||||
let turn = pb::ConversationTurnStructure {
|
||||
turn: Some(
|
||||
pb::conversation_turn_structure::Turn::AgentConversationTurn(
|
||||
pb::AgentConversationTurnStructure {
|
||||
user_message: user_id.as_bytes().to_vec(),
|
||||
steps: step_ids.iter().map(|id| id.as_bytes().to_vec()).collect(),
|
||||
request_id: None,
|
||||
encrypted_model: None,
|
||||
dynamic_tool_count: None,
|
||||
send_message_step_indices: Vec::new(),
|
||||
},
|
||||
),
|
||||
),
|
||||
};
|
||||
let mut encoded = Vec::new();
|
||||
turn.encode(&mut encoded)?;
|
||||
let mut edges = vec![BlobEdge {
|
||||
child: user_id,
|
||||
field_name: "agent_conversation_turn.user_message".into(),
|
||||
}];
|
||||
edges.extend(
|
||||
step_ids
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, child)| BlobEdge {
|
||||
child,
|
||||
field_name: format!("agent_conversation_turn.steps[{index}]"),
|
||||
}),
|
||||
);
|
||||
turn_ids.push(self.sync.persist(&encoded, &edges).await?);
|
||||
}
|
||||
Ok(turn_ids)
|
||||
}
|
||||
|
||||
pub async fn import_prefetched(&self, blobs: &[pb::PreFetchedBlob]) -> Result<()> {
|
||||
for blob in blobs {
|
||||
let expected = BlobId::from_bytes(&blob.id)?;
|
||||
let actual = self.store.put_blob(&blob.value, &[]).await?;
|
||||
if expected != actual {
|
||||
return Err(Error::Protocol(format!(
|
||||
"prefetched Blob hash mismatch: {}",
|
||||
expected.to_base64()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn hydrate_messages(
|
||||
&self,
|
||||
state: Option<&pb::ConversationStateStructure>,
|
||||
) -> Result<Vec<CanonicalMessage>> {
|
||||
let mut messages = Vec::new();
|
||||
let Some(state) = state else {
|
||||
return Ok(messages);
|
||||
};
|
||||
for raw_id in &state.root_prompt_messages_json {
|
||||
let id = BlobId::from_bytes(raw_id)?;
|
||||
let Some(data) = self.sync.get(&id).await? else {
|
||||
return Err(Error::Protocol(format!(
|
||||
"missing message Blob {}",
|
||||
id.to_base64()
|
||||
)));
|
||||
};
|
||||
messages.push(serde_json::from_slice(&data)?);
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
}
|
||||
|
||||
fn message_steps(
|
||||
message: &CanonicalMessage,
|
||||
completed: &HashSet<String>,
|
||||
) -> Result<Vec<pb::ConversationStep>> {
|
||||
use pb::conversation_step::Message;
|
||||
match &message.content {
|
||||
MessageContent::Assistant {
|
||||
text,
|
||||
thinking,
|
||||
tool_calls,
|
||||
..
|
||||
} => {
|
||||
let mut steps = Vec::new();
|
||||
if !thinking.is_empty() {
|
||||
steps.push(pb::ConversationStep {
|
||||
message: Some(Message::ThinkingMessage(pb::ThinkingMessage {
|
||||
text: thinking.clone(),
|
||||
duration_ms: 0,
|
||||
})),
|
||||
});
|
||||
}
|
||||
if !text.is_empty() {
|
||||
steps.push(pb::ConversationStep {
|
||||
message: Some(Message::AssistantMessage(pb::AssistantMessage {
|
||||
text: text.clone(),
|
||||
})),
|
||||
});
|
||||
}
|
||||
for call in tool_calls {
|
||||
let tool = render_tool_call(
|
||||
&ToolCall {
|
||||
index: 0,
|
||||
call_id: call.call_id.clone(),
|
||||
model_call_id: String::new(),
|
||||
name: call.name.clone(),
|
||||
arguments_text: serde_json::to_string(&call.arguments).unwrap_or_default(),
|
||||
arguments: call.arguments.clone(),
|
||||
},
|
||||
completed.contains(&call.call_id),
|
||||
)?;
|
||||
steps.push(pb::ConversationStep {
|
||||
message: Some(Message::ToolCall(tool)),
|
||||
});
|
||||
}
|
||||
Ok(steps)
|
||||
}
|
||||
_ => Ok(Vec::new()),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,226 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use prost::Message;
|
||||
|
||||
use crate::{
|
||||
cursor::{prompting::fold_derived_state, proto::agent::v1 as pb},
|
||||
model::{CanonicalMessage, MessageContent},
|
||||
store::BlobId,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::CheckpointBuilder;
|
||||
|
||||
impl CheckpointBuilder {
|
||||
pub(super) async fn build_derived_state(
|
||||
&self,
|
||||
messages: &[CanonicalMessage],
|
||||
) -> Result<(Vec<BlobId>, Option<BlobId>)> {
|
||||
let state = fold_derived_state(messages);
|
||||
let todo_values = state
|
||||
.todos
|
||||
.as_ref()
|
||||
.map(|value| {
|
||||
value
|
||||
.get("todos")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.ok_or_else(|| Error::Protocol("TodoWrite state is missing todos[]".into()))
|
||||
})
|
||||
.transpose()?;
|
||||
let mut todo_ids = Vec::new();
|
||||
for (index, todo) in todo_values.into_iter().flatten().enumerate() {
|
||||
let status = match todo
|
||||
.get("status")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol("TodoWrite item is missing status".into()))?
|
||||
{
|
||||
"in_progress" => pb::TodoStatus::InProgress,
|
||||
"completed" => pb::TodoStatus::Completed,
|
||||
"cancelled" => pb::TodoStatus::Cancelled,
|
||||
"pending" => pb::TodoStatus::Pending,
|
||||
status => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"unknown TodoWrite status: {status}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let message = pb::TodoItem {
|
||||
id: todo
|
||||
.get("id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol("TodoWrite item is missing id".into()))?
|
||||
.into(),
|
||||
content: todo
|
||||
.get("content")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol("TodoWrite item is missing content".into()))?
|
||||
.into(),
|
||||
status: status as i32,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
dependencies: todo
|
||||
.get("dependencies")
|
||||
.and_then(serde_json::Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(serde_json::Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
};
|
||||
let mut encoded = Vec::new();
|
||||
message.encode(&mut encoded)?;
|
||||
let id = BlobId::digest(&encoded);
|
||||
if self.base.todos.get(index).map(|raw| raw.as_slice()) == Some(id.as_bytes()) {
|
||||
todo_ids.push(id);
|
||||
} else {
|
||||
todo_ids.push(self.sync.persist(&encoded, &[]).await?);
|
||||
}
|
||||
}
|
||||
let plan_id = if let Some(value) = state.plan {
|
||||
let text = value
|
||||
.get("plan")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.or_else(|| value.as_str())
|
||||
.or_else(|| value.get("overview").and_then(serde_json::Value::as_str))
|
||||
.ok_or_else(|| Error::Protocol("plan state has no textual plan".into()))?;
|
||||
let mut encoded = Vec::new();
|
||||
pb::ConversationPlan { plan: text.into() }.encode(&mut encoded)?;
|
||||
let id = BlobId::digest(&encoded);
|
||||
if self.base.plan.as_deref() == Some(id.as_bytes()) {
|
||||
Some(id)
|
||||
} else {
|
||||
Some(self.sync.persist(&encoded, &[]).await?)
|
||||
}
|
||||
} else {
|
||||
None
|
||||
};
|
||||
Ok((todo_ids, plan_id))
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn update_current_step_state(
|
||||
messages: &[CanonicalMessage],
|
||||
) -> Option<pb::CommunicateUpdateTurnState> {
|
||||
let result_indices = messages
|
||||
.iter()
|
||||
.filter_map(|message| match &message.content {
|
||||
MessageContent::ToolResult(result) => {
|
||||
update_message_index(&result.content).map(|index| (result.call_id.as_str(), index))
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
.collect::<HashMap<_, _>>();
|
||||
let mut state = pb::CommunicateUpdateTurnState::default();
|
||||
for message in messages {
|
||||
let MessageContent::Assistant { tool_calls, .. } = &message.content else {
|
||||
continue;
|
||||
};
|
||||
for call in tool_calls {
|
||||
if normalize(&call.name) != "updatecurrentstep" {
|
||||
continue;
|
||||
}
|
||||
if let (Some(step), Some(message_index)) = (
|
||||
call.arguments
|
||||
.get("current_step")
|
||||
.and_then(serde_json::Value::as_str),
|
||||
result_indices.get(call.call_id.as_str()),
|
||||
) {
|
||||
state.history.push(pb::CommunicateUpdateHistoryEntry {
|
||||
step: step.into(),
|
||||
message_index: *message_index,
|
||||
});
|
||||
}
|
||||
if let Some(summary) = call
|
||||
.arguments
|
||||
.get("final_summary")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
state.final_summary = Some(summary.into());
|
||||
}
|
||||
if let Some(subtitle) = call
|
||||
.arguments
|
||||
.get("completed_subtitle")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
{
|
||||
state.completed_subtitle = Some(subtitle.into());
|
||||
}
|
||||
}
|
||||
}
|
||||
(!state.history.is_empty()
|
||||
|| state.final_summary.is_some()
|
||||
|| state.completed_subtitle.is_some())
|
||||
.then_some(state)
|
||||
}
|
||||
|
||||
fn update_message_index(output: &str) -> Option<u32> {
|
||||
let value: serde_json::Value = serde_json::from_str(output).ok()?;
|
||||
value
|
||||
.get("success")
|
||||
.and_then(|success| success.get("message_index"))
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.and_then(|index| u32::try_from(index).ok())
|
||||
}
|
||||
|
||||
fn normalize(name: &str) -> String {
|
||||
name.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::model::{Origin, Role, ToolCallContent, ToolResultContent};
|
||||
|
||||
#[test]
|
||||
fn update_current_step_is_folded_from_canonical_messages() {
|
||||
let messages = vec![
|
||||
CanonicalMessage {
|
||||
message_id: "assistant".into(),
|
||||
role: Role::Assistant,
|
||||
origin: Origin::Assistant,
|
||||
content: MessageContent::Assistant {
|
||||
text: String::new(),
|
||||
thinking: String::new(),
|
||||
tool_round_id: Some("round".into()),
|
||||
replay_state: None,
|
||||
tool_calls: vec![ToolCallContent {
|
||||
index: 0,
|
||||
call_id: "call".into(),
|
||||
name: "UpdateCurrentStep".into(),
|
||||
arguments: serde_json::json!({
|
||||
"current_step": "Inspecting protocol",
|
||||
"final_summary": "Protocol verified.",
|
||||
"completed_subtitle": "Verified protocol flow"
|
||||
}),
|
||||
}],
|
||||
},
|
||||
runtime_event_id: None,
|
||||
},
|
||||
CanonicalMessage {
|
||||
message_id: "result".into(),
|
||||
role: Role::Tool,
|
||||
origin: Origin::Tool,
|
||||
content: MessageContent::ToolResult(ToolResultContent {
|
||||
call_id: "call".into(),
|
||||
name: "UpdateCurrentStep".into(),
|
||||
content: serde_json::json!({
|
||||
"success": {"current_step": "Inspecting protocol", "message_index": 3}
|
||||
})
|
||||
.to_string(),
|
||||
is_error: false,
|
||||
}),
|
||||
runtime_event_id: None,
|
||||
},
|
||||
];
|
||||
let state = update_current_step_state(&messages).unwrap();
|
||||
assert_eq!(state.history[0].step, "Inspecting protocol");
|
||||
assert_eq!(state.history[0].message_index, 3);
|
||||
assert_eq!(state.final_summary.as_deref(), Some("Protocol verified."));
|
||||
assert_eq!(
|
||||
state.completed_subtitle.as_deref(),
|
||||
Some("Verified protocol flow")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,223 @@
|
||||
mod derived;
|
||||
mod recovery;
|
||||
mod roots;
|
||||
mod turns;
|
||||
pub(crate) mod worker;
|
||||
|
||||
use std::collections::HashSet;
|
||||
|
||||
use crate::{
|
||||
cursor::{
|
||||
blob_sync::BlobSynchronizer, presentation::PresentationDelta, projection,
|
||||
proto::agent::v1 as pb, CursorSessionHandle,
|
||||
},
|
||||
model::{CanonicalMessage, ToolCall, ToolDefinition, ToolRoundAssistant},
|
||||
store::Store,
|
||||
Result,
|
||||
};
|
||||
|
||||
use roots::RootFrontier;
|
||||
use turns::TurnFrontier;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CheckpointBuilder {
|
||||
store: Store,
|
||||
sync: BlobSynchronizer,
|
||||
parent_tool_call_id: Option<String>,
|
||||
base: pb::ConversationStateStructure,
|
||||
model: String,
|
||||
max_context_tokens: Option<u64>,
|
||||
instructions: String,
|
||||
tool_definitions: Vec<ToolDefinition>,
|
||||
allowed_tools: Vec<String>,
|
||||
dynamic_tools: HashSet<String>,
|
||||
turn_user: Option<pb::UserMessage>,
|
||||
roots: Option<RootFrontier>,
|
||||
turn: Option<TurnFrontier>,
|
||||
turns_initialized: bool,
|
||||
}
|
||||
|
||||
impl CheckpointBuilder {
|
||||
pub fn new(
|
||||
store: Store,
|
||||
sync: BlobSynchronizer,
|
||||
parent_tool_call_id: Option<String>,
|
||||
base: Option<pb::ConversationStateStructure>,
|
||||
) -> Self {
|
||||
Self {
|
||||
store,
|
||||
sync,
|
||||
parent_tool_call_id,
|
||||
base: base.unwrap_or_default(),
|
||||
model: String::new(),
|
||||
max_context_tokens: None,
|
||||
instructions: String::new(),
|
||||
tool_definitions: Vec::new(),
|
||||
allowed_tools: Vec::new(),
|
||||
dynamic_tools: HashSet::new(),
|
||||
turn_user: None,
|
||||
roots: None,
|
||||
turn: None,
|
||||
turns_initialized: false,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn configure(
|
||||
&mut self,
|
||||
model: String,
|
||||
max_context_tokens: Option<u64>,
|
||||
instructions: String,
|
||||
tool_definitions: Vec<ToolDefinition>,
|
||||
dynamic_tools: HashSet<String>,
|
||||
turn_user: Option<pb::UserMessage>,
|
||||
) {
|
||||
self.model = model;
|
||||
self.max_context_tokens = max_context_tokens;
|
||||
self.instructions = instructions;
|
||||
self.allowed_tools = tool_definitions
|
||||
.iter()
|
||||
.map(|tool| tool.name.clone())
|
||||
.collect();
|
||||
self.tool_definitions = tool_definitions;
|
||||
self.dynamic_tools = dynamic_tools;
|
||||
self.turn_user = turn_user;
|
||||
}
|
||||
|
||||
pub(crate) fn record_context_tokens(&mut self, used_tokens: Option<u64>) {
|
||||
let Some(used_tokens) = used_tokens else {
|
||||
return;
|
||||
};
|
||||
let max_tokens = self
|
||||
.base
|
||||
.token_details
|
||||
.as_ref()
|
||||
.map(|details| details.max_tokens as u64)
|
||||
.filter(|tokens| *tokens != 0)
|
||||
.or(self.max_context_tokens);
|
||||
let Some(max_tokens) = max_tokens else {
|
||||
return;
|
||||
};
|
||||
let details = self.base.token_details.get_or_insert_with(Default::default);
|
||||
details.used_tokens = used_tokens.min(u32::MAX as u64) as u32;
|
||||
details.max_tokens = max_tokens.min(u32::MAX as u64) as u32;
|
||||
details.prompt_context_usage_tree = None;
|
||||
details.prompt_context_usage_snapshot_blob_id = None;
|
||||
}
|
||||
|
||||
pub async fn settled(
|
||||
&mut self,
|
||||
messages: &[CanonicalMessage],
|
||||
mode: i32,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<pb::ConversationStateStructure> {
|
||||
self.build_state(messages, mode, Vec::new(), presentation)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn staged_tool_round(
|
||||
&mut self,
|
||||
stable_messages: &[CanonicalMessage],
|
||||
mode: i32,
|
||||
assistant: &ToolRoundAssistant,
|
||||
calls: &[ToolCall],
|
||||
started_at_ms: u64,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<pb::ConversationStateStructure> {
|
||||
let pending = projection::staged_tool_round(
|
||||
assistant,
|
||||
calls,
|
||||
&self.model,
|
||||
&self.allowed_tools,
|
||||
&self.dynamic_tools,
|
||||
started_at_ms,
|
||||
)?;
|
||||
self.build_state(stable_messages, mode, vec![pending], presentation)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn staged_final(
|
||||
&mut self,
|
||||
stable_messages: &[CanonicalMessage],
|
||||
mode: i32,
|
||||
assistant: &CanonicalMessage,
|
||||
started_at_ms: u64,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<pb::ConversationStateStructure> {
|
||||
let pending = projection::staged_final(
|
||||
assistant,
|
||||
&self.model,
|
||||
&self.allowed_tools,
|
||||
&self.dynamic_tools,
|
||||
started_at_ms,
|
||||
)?;
|
||||
self.build_state(stable_messages, mode, vec![pending], presentation)
|
||||
.await
|
||||
}
|
||||
|
||||
async fn build_state(
|
||||
&mut self,
|
||||
messages: &[CanonicalMessage],
|
||||
mode: i32,
|
||||
pending_tool_calls: Vec<String>,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<pb::ConversationStateStructure> {
|
||||
let root_ids = self.project_roots(messages).await?;
|
||||
let turn_ids = self.project_turns(mode, presentation).await?;
|
||||
let (todo_ids, plan_id) = self.build_derived_state(messages).await?;
|
||||
self.base.todos = todo_ids.iter().map(|id| id.as_bytes().to_vec()).collect();
|
||||
self.base.plan = plan_id.as_ref().map(|id| id.as_bytes().to_vec());
|
||||
let communicate_update_states_by_parent_tool_call_id = self
|
||||
.parent_tool_call_id
|
||||
.as_ref()
|
||||
.and_then(|parent| {
|
||||
derived::update_current_step_state(messages).map(|state| (parent.clone(), state))
|
||||
})
|
||||
.into_iter()
|
||||
.collect();
|
||||
|
||||
for path in &presentation.read_paths {
|
||||
if !self.base.read_paths.contains(path) {
|
||||
self.base.read_paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
let mut checkpoint = self.base.clone();
|
||||
checkpoint.root_prompt_messages_json =
|
||||
root_ids.iter().map(|id| id.as_bytes().to_vec()).collect();
|
||||
checkpoint.turns = turn_ids.iter().map(|id| id.as_bytes().to_vec()).collect();
|
||||
checkpoint.pending_tool_calls = pending_tool_calls;
|
||||
checkpoint.mode = Some(mode);
|
||||
checkpoint.communicate_update_states_by_parent_tool_call_id =
|
||||
communicate_update_states_by_parent_tool_call_id;
|
||||
if let Some(details) = checkpoint.token_details.as_mut() {
|
||||
details.breakdown = Some(crate::cursor::usage::breakdown(
|
||||
details.used_tokens,
|
||||
details.max_tokens,
|
||||
details.breakdown.as_ref(),
|
||||
&self.instructions,
|
||||
&self.tool_definitions,
|
||||
&self.dynamic_tools,
|
||||
messages,
|
||||
)?);
|
||||
}
|
||||
Ok(checkpoint)
|
||||
}
|
||||
|
||||
pub async fn publish(
|
||||
&self,
|
||||
handle: &CursorSessionHandle,
|
||||
checkpoint: &pb::ConversationStateStructure,
|
||||
) -> Result<()> {
|
||||
tracing::debug!(
|
||||
request_id = self.sync.request_id(),
|
||||
stable_roots = checkpoint.root_prompt_messages_json.len(),
|
||||
pending_assistants = checkpoint.pending_tool_calls.len(),
|
||||
"publishing Cursor checkpoint"
|
||||
);
|
||||
handle.emit(&pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(
|
||||
pb::agent_server_message::Message::ConversationCheckpointUpdate(checkpoint.clone()),
|
||||
),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
use crate::{
|
||||
cursor::{projection, proto::agent::v1 as pb},
|
||||
model::CanonicalMessage,
|
||||
store::BlobId,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::CheckpointBuilder;
|
||||
|
||||
impl CheckpointBuilder {
|
||||
pub async fn import_prefetched(&self, blobs: &[pb::PreFetchedBlob]) -> Result<()> {
|
||||
for blob in blobs {
|
||||
let expected = BlobId::from_bytes(&blob.id)?;
|
||||
let actual = self.store.put_blob(&blob.value, &[]).await?;
|
||||
if expected != actual {
|
||||
return Err(Error::Protocol(format!(
|
||||
"prefetched Blob hash mismatch: {}",
|
||||
expected.to_base64()
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn hydrate_messages(
|
||||
&self,
|
||||
state: Option<&pb::ConversationStateStructure>,
|
||||
) -> Result<Vec<CanonicalMessage>> {
|
||||
let mut messages = Vec::new();
|
||||
let Some(state) = state else {
|
||||
return Ok(messages);
|
||||
};
|
||||
for (ordinal, raw_id) in state.root_prompt_messages_json.iter().enumerate() {
|
||||
let id = BlobId::from_bytes(raw_id)?;
|
||||
let Some(data) = self.sync.get(&id).await? else {
|
||||
return Err(Error::Protocol(format!(
|
||||
"missing message Blob {}",
|
||||
id.to_base64()
|
||||
)));
|
||||
};
|
||||
messages.push(projection::decode(
|
||||
&data,
|
||||
format!("cursor-root:{}:{ordinal}", id.to_base64()),
|
||||
)?);
|
||||
}
|
||||
Ok(messages)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use crate::{cursor::projection, model::CanonicalMessage, store::BlobId, Error, Result};
|
||||
|
||||
use super::CheckpointBuilder;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct RootFrontier {
|
||||
pub(super) ids: Vec<BlobId>,
|
||||
pub(super) generated: Vec<Vec<u8>>,
|
||||
pub(super) base_count: usize,
|
||||
}
|
||||
|
||||
impl CheckpointBuilder {
|
||||
pub(super) async fn project_roots(
|
||||
&mut self,
|
||||
messages: &[CanonicalMessage],
|
||||
) -> Result<Vec<BlobId>> {
|
||||
let wire_messages = projection::stable_messages(&self.instructions, messages, &self.model)?;
|
||||
self.ensure_roots()?;
|
||||
let replacement = self
|
||||
.roots
|
||||
.as_ref()
|
||||
.and_then(|roots| changed_system_root(roots, &wire_messages));
|
||||
if let Some(message) = replacement {
|
||||
let id = self.sync.persist(&message, &[]).await?;
|
||||
self.roots
|
||||
.as_mut()
|
||||
.ok_or_else(|| Error::Protocol("Cursor root frontier was not initialized".into()))?
|
||||
.ids[0] = id;
|
||||
}
|
||||
let roots = self
|
||||
.roots
|
||||
.as_mut()
|
||||
.ok_or_else(|| Error::Protocol("Cursor root frontier was not initialized".into()))?;
|
||||
if wire_messages.len() < roots.ids.len() {
|
||||
return Err(Error::Protocol(format!(
|
||||
"Cursor stable history shrank from {} to {} roots",
|
||||
roots.ids.len(),
|
||||
wire_messages.len()
|
||||
)));
|
||||
}
|
||||
for (index, expected) in roots.generated.iter().enumerate() {
|
||||
let wire_index = roots.base_count + index;
|
||||
if wire_messages.get(wire_index) != Some(expected) {
|
||||
return Err(Error::Protocol(format!(
|
||||
"Cursor stable root changed at index {wire_index}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
for message in wire_messages.iter().skip(roots.ids.len()) {
|
||||
roots.ids.push(self.sync.persist(message, &[]).await?);
|
||||
roots.generated.push(message.clone());
|
||||
}
|
||||
Ok(roots.ids.clone())
|
||||
}
|
||||
|
||||
fn ensure_roots(&mut self) -> Result<()> {
|
||||
if self.roots.is_some() {
|
||||
return Ok(());
|
||||
}
|
||||
let ids = self
|
||||
.base
|
||||
.root_prompt_messages_json
|
||||
.iter()
|
||||
.map(|id| BlobId::from_bytes(id))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
self.roots = Some(RootFrontier {
|
||||
base_count: ids.len(),
|
||||
ids,
|
||||
generated: Vec::new(),
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn changed_system_root(roots: &RootFrontier, messages: &[Vec<u8>]) -> Option<Vec<u8>> {
|
||||
roots
|
||||
.ids
|
||||
.first()
|
||||
.zip(messages.first())
|
||||
.filter(|(current, message)| **current != BlobId::digest(message))
|
||||
.map(|(_, message)| message.clone())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn a_new_prompt_replaces_only_the_system_root() {
|
||||
let previous = b"previous prompt".to_vec();
|
||||
let current = b"current prompt".to_vec();
|
||||
let roots = RootFrontier {
|
||||
ids: vec![BlobId::digest(&previous), BlobId::digest(b"user")],
|
||||
generated: Vec::new(),
|
||||
base_count: 2,
|
||||
};
|
||||
assert_eq!(
|
||||
changed_system_root(&roots, &[current.clone(), b"user".to_vec()]),
|
||||
Some(current)
|
||||
);
|
||||
assert_eq!(
|
||||
changed_system_root(&roots, &[previous, b"user".to_vec()]),
|
||||
None
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,126 @@
|
||||
use prost::Message;
|
||||
|
||||
use crate::{
|
||||
cursor::{presentation::PresentationDelta, proto::agent::v1 as pb},
|
||||
store::{BlobEdge, BlobId},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::CheckpointBuilder;
|
||||
|
||||
#[derive(Clone)]
|
||||
pub(super) struct TurnFrontier {
|
||||
pub(super) preceding: Vec<BlobId>,
|
||||
pub(super) current_id: Option<BlobId>,
|
||||
pub(super) current: pb::AgentConversationTurnStructure,
|
||||
}
|
||||
|
||||
impl CheckpointBuilder {
|
||||
pub(super) async fn project_turns(
|
||||
&mut self,
|
||||
mode: i32,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<Vec<BlobId>> {
|
||||
self.ensure_turn(mode).await?;
|
||||
let Some(turn) = self.turn.as_mut() else {
|
||||
return self
|
||||
.base
|
||||
.turns
|
||||
.iter()
|
||||
.map(|id| BlobId::from_bytes(id))
|
||||
.collect();
|
||||
};
|
||||
let changed = !presentation.steps.is_empty();
|
||||
for step in &presentation.steps {
|
||||
let mut encoded = Vec::new();
|
||||
step.encode(&mut encoded)?;
|
||||
let id = self.sync.persist(&encoded, &[]).await?;
|
||||
turn.current.steps.push(id.as_bytes().to_vec());
|
||||
}
|
||||
if changed || turn.current_id.is_none() {
|
||||
let wrapper = pb::ConversationTurnStructure {
|
||||
turn: Some(
|
||||
pb::conversation_turn_structure::Turn::AgentConversationTurn(
|
||||
turn.current.clone(),
|
||||
),
|
||||
),
|
||||
};
|
||||
let mut encoded = Vec::new();
|
||||
wrapper.encode(&mut encoded)?;
|
||||
let mut edges = Vec::with_capacity(turn.current.steps.len() + 1);
|
||||
edges.push(BlobEdge {
|
||||
child: BlobId::from_bytes(&turn.current.user_message)?,
|
||||
field_name: "agent_conversation_turn.user_message".into(),
|
||||
});
|
||||
for (index, raw_id) in turn.current.steps.iter().enumerate() {
|
||||
edges.push(BlobEdge {
|
||||
child: BlobId::from_bytes(raw_id)?,
|
||||
field_name: format!("agent_conversation_turn.steps[{index}]"),
|
||||
});
|
||||
}
|
||||
turn.current_id = Some(self.sync.persist(&encoded, &edges).await?);
|
||||
}
|
||||
let mut ids = turn.preceding.clone();
|
||||
ids.push(
|
||||
turn.current_id
|
||||
.clone()
|
||||
.ok_or_else(|| Error::Protocol("Cursor current Turn has no BlobID".into()))?,
|
||||
);
|
||||
Ok(ids)
|
||||
}
|
||||
|
||||
async fn ensure_turn(&mut self, mode: i32) -> Result<()> {
|
||||
if self.turns_initialized {
|
||||
return Ok(());
|
||||
}
|
||||
self.turns_initialized = true;
|
||||
let base_ids = self
|
||||
.base
|
||||
.turns
|
||||
.iter()
|
||||
.map(|id| BlobId::from_bytes(id))
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
if let Some(mut user) = self.turn_user.clone() {
|
||||
user.mode = mode;
|
||||
let mut encoded = Vec::new();
|
||||
user.encode(&mut encoded)?;
|
||||
let user_id = self.sync.persist(&encoded, &[]).await?;
|
||||
self.turn = Some(TurnFrontier {
|
||||
preceding: base_ids,
|
||||
current_id: None,
|
||||
current: pb::AgentConversationTurnStructure {
|
||||
user_message: user_id.as_bytes().to_vec(),
|
||||
steps: Vec::new(),
|
||||
request_id: Some(self.sync.request_id().into()),
|
||||
encrypted_model: None,
|
||||
dynamic_tool_count: None,
|
||||
send_message_step_indices: Vec::new(),
|
||||
},
|
||||
});
|
||||
return Ok(());
|
||||
}
|
||||
let Some((current_id, preceding)) = base_ids.split_last() else {
|
||||
return Ok(());
|
||||
};
|
||||
let data = self.sync.get(current_id).await?.ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"missing current Turn Blob {}",
|
||||
current_id.to_base64()
|
||||
))
|
||||
})?;
|
||||
let wrapper = pb::ConversationTurnStructure::decode(data.as_slice())?;
|
||||
let Some(pb::conversation_turn_structure::Turn::AgentConversationTurn(current)) =
|
||||
wrapper.turn
|
||||
else {
|
||||
return Err(Error::Protocol(
|
||||
"current Cursor Turn is not an agent conversation turn".into(),
|
||||
));
|
||||
};
|
||||
self.turn = Some(TurnFrontier {
|
||||
preceding: preceding.to_vec(),
|
||||
current_id: Some(current_id.clone()),
|
||||
current,
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::{
|
||||
cursor::{presentation::PresentationDelta, proto::agent::v1 as pb, CursorSessionHandle},
|
||||
model::{RevisionId, ToolRoundId},
|
||||
store::Store,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::CheckpointBuilder;
|
||||
|
||||
pub(crate) struct CheckpointJob {
|
||||
pub kind: CheckpointKind,
|
||||
pub presentation: PresentationDelta,
|
||||
pub context_tokens: Option<u64>,
|
||||
pub ready: Option<oneshot::Sender<std::result::Result<(), String>>>,
|
||||
}
|
||||
|
||||
pub(crate) enum CheckpointKind {
|
||||
Settled(RevisionId),
|
||||
ToolStarted {
|
||||
round_id: ToolRoundId,
|
||||
stable_revision_id: RevisionId,
|
||||
},
|
||||
ToolSettled(RevisionId),
|
||||
Final {
|
||||
revision_id: RevisionId,
|
||||
result: oneshot::Sender<Result<FinalCheckpoints>>,
|
||||
},
|
||||
}
|
||||
|
||||
pub(crate) struct FinalCheckpoints {
|
||||
pub staged: pb::ConversationStateStructure,
|
||||
pub settled: pb::ConversationStateStructure,
|
||||
}
|
||||
|
||||
pub(crate) struct CheckpointWorker {
|
||||
pub jobs: mpsc::Sender<CheckpointJob>,
|
||||
pub failures: mpsc::Receiver<Error>,
|
||||
task: tokio::task::JoinHandle<()>,
|
||||
}
|
||||
|
||||
impl CheckpointWorker {
|
||||
pub fn spawn(
|
||||
store: Store,
|
||||
mut builder: CheckpointBuilder,
|
||||
handle: CursorSessionHandle,
|
||||
mode: i32,
|
||||
) -> Self {
|
||||
let (jobs, mut receiver) = mpsc::channel::<CheckpointJob>(32);
|
||||
let (failures, failure_receiver) = mpsc::channel(1);
|
||||
let task = tokio::spawn(async move {
|
||||
while let Some(job) = receiver.recv().await {
|
||||
builder.record_context_tokens(job.context_tokens);
|
||||
let presentation = job.presentation;
|
||||
let ready = job.ready;
|
||||
let result = match job.kind {
|
||||
CheckpointKind::Settled(revision_id)
|
||||
| CheckpointKind::ToolSettled(revision_id) => {
|
||||
publish_settled(
|
||||
&store,
|
||||
&mut builder,
|
||||
&handle,
|
||||
mode,
|
||||
revision_id,
|
||||
&presentation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
CheckpointKind::ToolStarted {
|
||||
round_id,
|
||||
stable_revision_id,
|
||||
} => {
|
||||
publish_started(
|
||||
&store,
|
||||
&mut builder,
|
||||
&handle,
|
||||
mode,
|
||||
round_id,
|
||||
stable_revision_id,
|
||||
&presentation,
|
||||
)
|
||||
.await
|
||||
}
|
||||
CheckpointKind::Final {
|
||||
revision_id,
|
||||
result,
|
||||
} => {
|
||||
let checkpoints =
|
||||
build_final(&store, &mut builder, mode, revision_id, &presentation)
|
||||
.await;
|
||||
let _ = result.send(checkpoints);
|
||||
Ok(())
|
||||
}
|
||||
};
|
||||
|
||||
if let Err(error) = result {
|
||||
if let Some(ready) = ready {
|
||||
let _ = ready.send(Err(error.to_string()));
|
||||
}
|
||||
tracing::error!(%error, "failed to build or publish Cursor checkpoint");
|
||||
let _ = failures.send(error).await;
|
||||
break;
|
||||
}
|
||||
if let Some(ready) = ready {
|
||||
let _ = ready.send(Ok(()));
|
||||
}
|
||||
}
|
||||
});
|
||||
Self {
|
||||
jobs,
|
||||
failures: failure_receiver,
|
||||
task,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn abort(&self) {
|
||||
self.task.abort();
|
||||
}
|
||||
}
|
||||
|
||||
async fn publish_settled(
|
||||
store: &Store,
|
||||
builder: &mut CheckpointBuilder,
|
||||
handle: &CursorSessionHandle,
|
||||
mode: i32,
|
||||
revision_id: RevisionId,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<()> {
|
||||
let messages = store.load_revision_messages(revision_id).await?;
|
||||
let checkpoint = builder.settled(&messages, mode, presentation).await?;
|
||||
builder.publish(handle, &checkpoint).await
|
||||
}
|
||||
|
||||
async fn publish_started(
|
||||
store: &Store,
|
||||
builder: &mut CheckpointBuilder,
|
||||
handle: &CursorSessionHandle,
|
||||
mode: i32,
|
||||
round_id: ToolRoundId,
|
||||
stable_revision_id: RevisionId,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<()> {
|
||||
let round = store
|
||||
.tool_round(&round_id)
|
||||
.await?
|
||||
.ok_or_else(|| Error::Store(format!("checkpoint tool round not found: {round_id}")))?;
|
||||
let messages = store.load_revision_messages(stable_revision_id).await?;
|
||||
let checkpoint = builder
|
||||
.staged_tool_round(
|
||||
&messages,
|
||||
mode,
|
||||
&round.assistant,
|
||||
&round.calls,
|
||||
round.created_at_ms,
|
||||
presentation,
|
||||
)
|
||||
.await?;
|
||||
builder.publish(handle, &checkpoint).await
|
||||
}
|
||||
|
||||
async fn build_final(
|
||||
store: &Store,
|
||||
builder: &mut CheckpointBuilder,
|
||||
mode: i32,
|
||||
revision_id: RevisionId,
|
||||
presentation: &PresentationDelta,
|
||||
) -> Result<FinalCheckpoints> {
|
||||
let messages = store.load_revision_messages(revision_id).await?;
|
||||
let (assistant, stable) = messages
|
||||
.split_last()
|
||||
.ok_or_else(|| Error::Store("final revision contains no assistant".into()))?;
|
||||
let started_at_ms = crate::cursor::tools::runtime::now_ms();
|
||||
let staged = builder
|
||||
.staged_final(stable, mode, assistant, started_at_ms, presentation)
|
||||
.await?;
|
||||
let settled = builder
|
||||
.settled(&messages, mode, &PresentationDelta::default())
|
||||
.await?;
|
||||
Ok(FinalCheckpoints { staged, settled })
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use crate::cursor::proto::agent::v1 as pb;
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RunCommand {
|
||||
pub enum CursorCommand {
|
||||
Append {
|
||||
seqno: i64,
|
||||
message: Box<pb::AgentClientMessage>,
|
||||
@@ -94,7 +94,7 @@ fn encode_end_stream_payload(payload: &[u8]) -> Bytes {
|
||||
pub fn decode_unary<M: Message + Default>(body: &[u8]) -> Result<M> {
|
||||
if body.len() >= 5 {
|
||||
let flags = body[0];
|
||||
let length = u32::from_be_bytes(body[1..5].try_into().expect("four bytes")) as usize;
|
||||
let length = u32::from_be_bytes([body[1], body[2], body[3], body[4]]) as usize;
|
||||
if flags & END_STREAM_FLAG == 0 && length == body.len() - 5 {
|
||||
return Ok(M::decode(&body[5..])?);
|
||||
}
|
||||
@@ -109,7 +109,7 @@ pub fn decode_frames(mut body: &[u8]) -> Result<Vec<(u8, Bytes)>> {
|
||||
return Err(Error::Protocol("truncated Connect envelope".into()));
|
||||
}
|
||||
let flags = body[0];
|
||||
let length = u32::from_be_bytes(body[1..5].try_into().expect("four bytes")) as usize;
|
||||
let length = u32::from_be_bytes([body[1], body[2], body[3], body[4]]) as usize;
|
||||
body = &body[5..];
|
||||
if body.len() < length {
|
||||
return Err(Error::Protocol("truncated Connect payload".into()));
|
||||
|
||||
@@ -1,535 +0,0 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::{
|
||||
cursor::{
|
||||
pending::{ExecContext, PendingExecRegistry},
|
||||
proto::agent::v1 as pb,
|
||||
tool_result::ToolCompletion,
|
||||
},
|
||||
model::ToolCall,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::AgentServerMessage> {
|
||||
use pb::exec_server_message::Message;
|
||||
let string = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
|
||||
};
|
||||
let optional_string = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
};
|
||||
let int = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_i64)
|
||||
.map(|v| v as i32)
|
||||
};
|
||||
let message = match normalize(&call.name).as_str() {
|
||||
"shell" => Message::ShellStreamArgs(pb::ShellArgs {
|
||||
command: string("command")?,
|
||||
working_directory: optional_string("working_directory").unwrap_or_default(),
|
||||
timeout: shell_timeout(call)?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
file_output_threshold_bytes: Some(40_000),
|
||||
timeout_behavior: pb::TimeoutBehavior::Background as i32,
|
||||
hard_timeout: Some(86_400_000),
|
||||
description: optional_string("description"),
|
||||
close_stdin: true,
|
||||
conversation_id: Some(context.conversation_id.clone()),
|
||||
admin_command_denylist: context.admin_command_denylist.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
"forcebackgroundshell" => Message::ForceBackgroundShellArgs(pb::ForceBackgroundShellArgs {
|
||||
tool_call_id: string("tool_call_id")?,
|
||||
}),
|
||||
"read" => Message::ReadArgs(pb::ReadArgs {
|
||||
path: string("path")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
offset: int("offset"),
|
||||
limit: call
|
||||
.arguments
|
||||
.get("limit")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|v| v as u32),
|
||||
encoding_hint: optional_string("encoding_hint"),
|
||||
}),
|
||||
"write" => Message::WriteArgs(pb::WriteArgs {
|
||||
path: string("path")?,
|
||||
file_text: string("contents")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
return_file_content_after_write: true,
|
||||
file_bytes: Vec::new(),
|
||||
encoding_hint: optional_string("encoding_hint"),
|
||||
}),
|
||||
"delete" => Message::DeleteArgs(pb::DeleteArgs {
|
||||
path: string("path")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
"grep" => Message::GrepArgs(pb::GrepArgs {
|
||||
pattern: string("pattern")?,
|
||||
path: optional_string("path"),
|
||||
glob: optional_string("glob"),
|
||||
output_mode: optional_string("output_mode"),
|
||||
context_before: int("context_before"),
|
||||
context_after: int("context_after"),
|
||||
context: int("context"),
|
||||
case_insensitive: call
|
||||
.arguments
|
||||
.get("case_insensitive")
|
||||
.and_then(Value::as_bool),
|
||||
r#type: optional_string("type"),
|
||||
head_limit: int("head_limit"),
|
||||
multiline: call.arguments.get("multiline").and_then(Value::as_bool),
|
||||
sort: optional_string("sort"),
|
||||
sort_ascending: call
|
||||
.arguments
|
||||
.get("sort_ascending")
|
||||
.and_then(Value::as_bool),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
sandbox_policy: None,
|
||||
offset: int("offset"),
|
||||
}),
|
||||
"glob" => Message::GrepArgs(pb::GrepArgs {
|
||||
pattern: String::new(),
|
||||
path: optional_string("target_directory"),
|
||||
glob: optional_string("glob_pattern"),
|
||||
output_mode: Some("files_with_matches".into()),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
"ls" => Message::LsArgs(pb::LsArgs {
|
||||
path: string("path")?,
|
||||
ignore: call
|
||||
.arguments
|
||||
.get("ignore")
|
||||
.and_then(Value::as_array)
|
||||
.map(|v| {
|
||||
v.iter()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect()
|
||||
})
|
||||
.unwrap_or_default(),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
sandbox_policy: None,
|
||||
timeout_ms: call
|
||||
.arguments
|
||||
.get("timeout_ms")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|v| v as u32),
|
||||
}),
|
||||
"readlints" => Message::DiagnosticsArgs(pb::DiagnosticsArgs {
|
||||
path: call
|
||||
.arguments
|
||||
.get("paths")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|paths| paths.first())
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
"patchedit" => Message::PiEditArgs(pb::PiEditExecArgs {
|
||||
path: string("path")?,
|
||||
edits: vec![pb::PiEditReplacement {
|
||||
old_text: string("old_string")?,
|
||||
new_text: string("new_string")?,
|
||||
}],
|
||||
}),
|
||||
"writeshellstdin" => Message::WriteShellStdinArgs(pb::WriteShellStdinArgs {
|
||||
shell_id: call
|
||||
.arguments
|
||||
.get("shell_id")
|
||||
.and_then(Value::as_u64)
|
||||
.unwrap_or_default() as u32,
|
||||
chars: string("chars")?,
|
||||
}),
|
||||
"task" => Message::SubagentArgs(pb::SubagentArgs {
|
||||
tool_call_id: call.call_id.clone(),
|
||||
subagent_type: string("subagent_type")?,
|
||||
model_id: optional_string("model").unwrap_or_default(),
|
||||
prompt: string("prompt")?,
|
||||
readonly: call
|
||||
.arguments
|
||||
.get("readonly")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
resume_agent_id: optional_string("resume"),
|
||||
run_in_background: Some(false),
|
||||
continuation_config: None,
|
||||
parent_conversation_id: None,
|
||||
interrupt: None,
|
||||
mode: 0,
|
||||
fork_agent_id: None,
|
||||
root_parent_conversation_id: None,
|
||||
selected_context: None,
|
||||
direct_meta_parent_child_subagent: None,
|
||||
environment: 0,
|
||||
cloud_base_branch: None,
|
||||
credentials: None,
|
||||
}),
|
||||
"callmcptool" => Message::McpArgs(pb::McpArgs {
|
||||
name: string("toolName")?,
|
||||
args: call
|
||||
.arguments
|
||||
.get("arguments")
|
||||
.and_then(Value::as_object)
|
||||
.map(json_object_to_prost)
|
||||
.unwrap_or_default(),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
provider_identifier: optional_string("provider_identifier").unwrap_or_default(),
|
||||
tool_name: string("toolName")?,
|
||||
smart_mode_approval: None,
|
||||
smart_mode_approval_only: false,
|
||||
skip_approval: false,
|
||||
server_identifier: string("server")?,
|
||||
}),
|
||||
"fetchmcpresource" => Message::ReadMcpResourceExecArgs(pb::ReadMcpResourceExecArgs {
|
||||
server: string("server")?,
|
||||
uri: string("uri")?,
|
||||
download_path: optional_string("downloadPath"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
smart_mode_approval: None,
|
||||
}),
|
||||
"webfetch" => Message::FetchArgs(pb::FetchArgs {
|
||||
url: string("url")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
other => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"tool {other} is not executed through ExecServerMessage"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::ExecServerMessage(
|
||||
pb::ExecServerMessage {
|
||||
id,
|
||||
exec_id: call.call_id.clone(),
|
||||
span_context: None,
|
||||
accept_hook_additional_contexts: Some(true),
|
||||
message: Some(message),
|
||||
},
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mcp_request(
|
||||
id: u32,
|
||||
call: &ToolCall,
|
||||
definition: &pb::McpToolDefinition,
|
||||
) -> Result<pb::AgentServerMessage> {
|
||||
let args = call
|
||||
.arguments
|
||||
.as_object()
|
||||
.map(json_object_to_prost)
|
||||
.unwrap_or_default();
|
||||
Ok(pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::ExecServerMessage(
|
||||
pb::ExecServerMessage {
|
||||
id,
|
||||
exec_id: call.call_id.clone(),
|
||||
span_context: None,
|
||||
accept_hook_additional_contexts: None,
|
||||
message: Some(pb::exec_server_message::Message::McpArgs(pb::McpArgs {
|
||||
name: definition.name.clone(),
|
||||
args,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
provider_identifier: definition.provider_identifier.clone(),
|
||||
tool_name: if definition.tool_name.is_empty() {
|
||||
definition.name.clone()
|
||||
} else {
|
||||
definition.tool_name.clone()
|
||||
},
|
||||
smart_mode_approval: None,
|
||||
smart_mode_approval_only: false,
|
||||
skip_approval: false,
|
||||
server_identifier: String::new(),
|
||||
})),
|
||||
},
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn abort(id: u32) -> pb::AgentServerMessage {
|
||||
pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::ExecServerControlMessage(
|
||||
pb::ExecServerControlMessage {
|
||||
message: Some(pb::exec_server_control_message::Message::Abort(
|
||||
pb::ExecServerAbort { id },
|
||||
)),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub enum ClientExecEvent {
|
||||
Delta(Box<pb::AgentServerMessage>),
|
||||
Completed(Box<ToolCompletion>),
|
||||
Pending,
|
||||
}
|
||||
|
||||
pub async fn client_event(
|
||||
message: &pb::ExecClientMessage,
|
||||
pending: &PendingExecRegistry,
|
||||
) -> Result<ClientExecEvent> {
|
||||
let call = pending
|
||||
.call(message.id)
|
||||
.await
|
||||
.ok_or_else(|| Error::Protocol(format!("unknown ExecClientMessage id: {}", message.id)))?;
|
||||
let Some(wire_result) = &message.message else {
|
||||
return Ok(ClientExecEvent::Pending);
|
||||
};
|
||||
let pb::exec_client_message::Message::ShellStream(stream) = wire_result else {
|
||||
return complete(message.id, pending, wire_result.clone()).await;
|
||||
};
|
||||
use pb::shell_stream::Event;
|
||||
let event = match &stream.event {
|
||||
Some(Event::Stdout(stdout)) => {
|
||||
if pending.append_stdout(message.id, &stdout.data).await {
|
||||
ClientExecEvent::Delta(Box::new(shell_delta(&call, true, &stdout.data)))
|
||||
} else {
|
||||
ClientExecEvent::Pending
|
||||
}
|
||||
}
|
||||
Some(Event::Stderr(stderr)) => {
|
||||
if pending.append_stderr(message.id, &stderr.data).await {
|
||||
ClientExecEvent::Delta(Box::new(shell_delta(&call, false, &stderr.data)))
|
||||
} else {
|
||||
ClientExecEvent::Pending
|
||||
}
|
||||
}
|
||||
Some(Event::Start(_)) | Some(Event::HookContext(_)) => ClientExecEvent::Pending,
|
||||
Some(Event::Exit(exit)) => {
|
||||
let entry = take(message.id, pending).await?;
|
||||
let result = shell_exit_result(message, exit, &entry.stdout, &entry.stderr);
|
||||
completed(entry, pb::exec_client_message::Message::ShellResult(result))?
|
||||
}
|
||||
Some(Event::Backgrounded(backgrounded)) => {
|
||||
let entry = take(message.id, pending).await?;
|
||||
let result = shell_backgrounded_result(
|
||||
backgrounded,
|
||||
&entry.stdout,
|
||||
&entry.stderr,
|
||||
&entry.context.terminals_folder,
|
||||
);
|
||||
completed(entry, pb::exec_client_message::Message::ShellResult(result))?
|
||||
}
|
||||
Some(Event::Rejected(value)) => {
|
||||
let result = pb::ShellResult {
|
||||
result: Some(pb::shell_result::Result::Rejected(value.clone())),
|
||||
..Default::default()
|
||||
};
|
||||
complete(
|
||||
message.id,
|
||||
pending,
|
||||
pb::exec_client_message::Message::ShellResult(result),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Some(Event::PermissionDenied(value)) => {
|
||||
let result = pb::ShellResult {
|
||||
result: Some(pb::shell_result::Result::PermissionDenied(value.clone())),
|
||||
..Default::default()
|
||||
};
|
||||
complete(
|
||||
message.id,
|
||||
pending,
|
||||
pb::exec_client_message::Message::ShellResult(result),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Some(Event::SandboxUnsupported(value)) => {
|
||||
let result = pb::ShellResult {
|
||||
result: Some(pb::shell_result::Result::SpawnError(pb::ShellSpawnError {
|
||||
command: value.command.clone(),
|
||||
working_directory: value.working_directory.clone(),
|
||||
error: value.reason.clone(),
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
complete(
|
||||
message.id,
|
||||
pending,
|
||||
pb::exec_client_message::Message::ShellResult(result),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => ClientExecEvent::Pending,
|
||||
};
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
async fn complete(
|
||||
id: u32,
|
||||
pending: &PendingExecRegistry,
|
||||
result: pb::exec_client_message::Message,
|
||||
) -> Result<ClientExecEvent> {
|
||||
completed(take(id, pending).await?, result)
|
||||
}
|
||||
|
||||
async fn take(id: u32, pending: &PendingExecRegistry) -> Result<super::pending::PendingExec> {
|
||||
pending
|
||||
.take(id)
|
||||
.await
|
||||
.ok_or_else(|| Error::Protocol(format!("unknown terminal Exec id: {id}")))
|
||||
}
|
||||
|
||||
fn completed(
|
||||
pending: super::pending::PendingExec,
|
||||
result: pb::exec_client_message::Message,
|
||||
) -> Result<ClientExecEvent> {
|
||||
Ok(ClientExecEvent::Completed(Box::new(
|
||||
super::tool_result::from_exec(pending, &result)?,
|
||||
)))
|
||||
}
|
||||
|
||||
fn shell_exit_result(
|
||||
message: &pb::ExecClientMessage,
|
||||
exit: &pb::ShellStreamExit,
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
) -> pb::ShellResult {
|
||||
let result = if exit.code == 0 && !exit.aborted {
|
||||
pb::shell_result::Result::Success(pb::ShellSuccess {
|
||||
working_directory: exit.cwd.clone(),
|
||||
exit_code: exit.code as i32,
|
||||
stdout: stdout.into(),
|
||||
stderr: stderr.into(),
|
||||
interleaved_output: Some(format!("{stdout}{stderr}")),
|
||||
local_execution_time_ms: exit
|
||||
.local_execution_time_ms
|
||||
.or(message.local_execution_time_ms),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
pb::shell_result::Result::Failure(pb::ShellFailure {
|
||||
working_directory: exit.cwd.clone(),
|
||||
exit_code: exit.code as i32,
|
||||
stdout: stdout.into(),
|
||||
stderr: stderr.into(),
|
||||
interleaved_output: Some(format!("{stdout}{stderr}")),
|
||||
abort_reason: exit.abort_reason,
|
||||
aborted: exit.aborted,
|
||||
local_execution_time_ms: exit
|
||||
.local_execution_time_ms
|
||||
.or(message.local_execution_time_ms),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
pb::ShellResult {
|
||||
result: Some(result),
|
||||
is_background: Some(false),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_backgrounded_result(
|
||||
backgrounded: &pb::ShellStreamBackgrounded,
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
terminals_folder: &str,
|
||||
) -> pb::ShellResult {
|
||||
pb::ShellResult {
|
||||
result: Some(pb::shell_result::Result::Success(pb::ShellSuccess {
|
||||
command: backgrounded.command.clone(),
|
||||
working_directory: backgrounded.working_directory.clone(),
|
||||
stdout: stdout.into(),
|
||||
stderr: stderr.into(),
|
||||
shell_id: Some(backgrounded.shell_id),
|
||||
pid: backgrounded.pid,
|
||||
ms_to_wait: backgrounded.ms_to_wait,
|
||||
background_reason: backgrounded.reason,
|
||||
interleaved_output: Some(format!("{stdout}{stderr}")),
|
||||
..Default::default()
|
||||
})),
|
||||
is_background: Some(true),
|
||||
terminals_folder: (!terminals_folder.is_empty()).then(|| terminals_folder.into()),
|
||||
pid: backgrounded.pid,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_delta(call: &ToolCall, stdout: bool, content: &str) -> pb::AgentServerMessage {
|
||||
let delta = if stdout {
|
||||
pb::shell_tool_call_delta::Delta::Stdout(pb::ShellToolCallStdoutDelta {
|
||||
content: content.into(),
|
||||
})
|
||||
} else {
|
||||
pb::shell_tool_call_delta::Delta::Stderr(pb::ShellToolCallStderrDelta {
|
||||
content: content.into(),
|
||||
})
|
||||
};
|
||||
super::interaction::server_interaction(pb::interaction_update::Message::ToolCallDelta(
|
||||
Box::new(pb::ToolCallDeltaUpdate {
|
||||
call_id: call.call_id.clone(),
|
||||
tool_call_delta: Some(Box::new(pb::ToolCallDelta {
|
||||
delta: Some(pb::tool_call_delta::Delta::ShellToolCallDelta(
|
||||
pb::ShellToolCallDelta { delta: Some(delta) },
|
||||
)),
|
||||
})),
|
||||
model_call_id: call.model_call_id.clone(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
fn shell_timeout(call: &ToolCall) -> Result<i32> {
|
||||
let value = call
|
||||
.arguments
|
||||
.get("block_until_ms")
|
||||
.map(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.ok_or_else(|| Error::Protocol("Shell block_until_ms must be an integer".into()))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(30_000);
|
||||
i32::try_from(value)
|
||||
.ok()
|
||||
.filter(|value| *value >= 0)
|
||||
.ok_or_else(|| Error::Protocol("Shell block_until_ms is out of range".into()))
|
||||
}
|
||||
|
||||
fn normalize(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn json_object_to_prost(
|
||||
value: &Map<String, Value>,
|
||||
) -> std::collections::HashMap<String, prost_types::Value> {
|
||||
value
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), prost_value(value)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn prost_value(value: &Value) -> prost_types::Value {
|
||||
use prost_types::{value::Kind, ListValue, Struct, Value as ProstValue};
|
||||
let kind = match value {
|
||||
Value::Null => Kind::NullValue(0),
|
||||
Value::Bool(v) => Kind::BoolValue(*v),
|
||||
Value::Number(v) => Kind::NumberValue(v.as_f64().unwrap_or_default()),
|
||||
Value::String(v) => Kind::StringValue(v.clone()),
|
||||
Value::Array(v) => Kind::ListValue(ListValue {
|
||||
values: v.iter().map(prost_value).collect(),
|
||||
}),
|
||||
Value::Object(v) => Kind::StructValue(Struct {
|
||||
fields: json_object_to_prost(v).into_iter().collect(),
|
||||
}),
|
||||
};
|
||||
ProstValue { kind: Some(kind) }
|
||||
}
|
||||
@@ -1,7 +1,7 @@
|
||||
use axum::{
|
||||
body::Bytes,
|
||||
extract::{DefaultBodyLimit, State},
|
||||
http::{header, HeaderValue, Response, StatusCode},
|
||||
extract::{DefaultBodyLimit, Extension, State},
|
||||
http::{header, HeaderMap, HeaderValue, Response, StatusCode},
|
||||
routing::post,
|
||||
Router,
|
||||
};
|
||||
@@ -9,28 +9,45 @@ use tower_http::decompression::RequestDecompressionLayer;
|
||||
|
||||
use crate::{
|
||||
cursor::{
|
||||
bidi_append, connect,
|
||||
bidi_append, connect, model_catalog,
|
||||
proto::{agent::v1 as agent, aiserver::v1 as ai},
|
||||
proxy::{self, CursorProxy},
|
||||
run_sse,
|
||||
},
|
||||
run::RunRegistry,
|
||||
cursor::{CursorParent, CursorSessionRegistry},
|
||||
Result,
|
||||
};
|
||||
|
||||
pub fn router(registry: RunRegistry) -> Router {
|
||||
Router::new()
|
||||
pub fn router(registry: CursorSessionRegistry) -> Result<Router> {
|
||||
let proxy = CursorProxy::cursor()?;
|
||||
Ok(Router::new()
|
||||
.route("/agent.v1.AgentService/RunSSE", post(run_sse_handler))
|
||||
.route(
|
||||
"/aiserver.v1.BidiService/BidiAppend",
|
||||
post(bidi_append_handler),
|
||||
)
|
||||
.layer(DefaultBodyLimit::disable())
|
||||
.layer(RequestDecompressionLayer::new())
|
||||
.with_state(registry)
|
||||
.route(
|
||||
"/aiserver.v1.AiService/AvailableModels",
|
||||
post(model_catalog::available_models),
|
||||
)
|
||||
.route(
|
||||
"/agent.v1.AgentService/GetUsableModels",
|
||||
post(model_catalog::usable_models),
|
||||
)
|
||||
.route(
|
||||
"/aiserver.v1.AiService/GetUsableModels",
|
||||
post(model_catalog::usable_models),
|
||||
)
|
||||
.route_layer(DefaultBodyLimit::disable())
|
||||
.route_layer(RequestDecompressionLayer::new())
|
||||
.fallback(proxy::forward)
|
||||
.method_not_allowed_fallback(proxy::forward)
|
||||
.layer(Extension(proxy))
|
||||
.with_state(registry))
|
||||
}
|
||||
|
||||
async fn run_sse_handler(
|
||||
State(registry): State<RunRegistry>,
|
||||
State(registry): State<CursorSessionRegistry>,
|
||||
body: Bytes,
|
||||
) -> Result<Response<axum::body::Body>> {
|
||||
let request: agent::BidiRequestId = connect::decode_unary(&body)?;
|
||||
@@ -38,11 +55,13 @@ async fn run_sse_handler(
|
||||
}
|
||||
|
||||
async fn bidi_append_handler(
|
||||
State(registry): State<RunRegistry>,
|
||||
State(registry): State<CursorSessionRegistry>,
|
||||
headers: HeaderMap,
|
||||
body: Bytes,
|
||||
) -> Result<Response<axum::body::Body>> {
|
||||
let request: ai::BidiAppendRequest = connect::decode_unary(&body)?;
|
||||
bidi_append::append(®istry, request).await?;
|
||||
let parent = parent_headers(&headers)?;
|
||||
bidi_append::append(®istry, request, parent).await?;
|
||||
let mut response = Response::new(axum::body::Body::empty());
|
||||
*response.status_mut() = StatusCode::OK;
|
||||
response.headers_mut().insert(
|
||||
@@ -51,3 +70,53 @@ async fn bidi_append_handler(
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
fn parent_headers(headers: &HeaderMap) -> Result<Option<CursorParent>> {
|
||||
let run_id = header_text(headers, "x-parent-request-id")?;
|
||||
let tool_call_id = header_text(headers, "x-parent-agent-tool-call-id")?;
|
||||
match (run_id, tool_call_id) {
|
||||
(None, None) => Ok(None),
|
||||
(Some(run_id), Some(tool_call_id)) => Ok(Some(CursorParent {
|
||||
run_id: run_id.into(),
|
||||
tool_call_id: tool_call_id.into(),
|
||||
})),
|
||||
_ => Err(crate::Error::Protocol(
|
||||
"Cursor subagent request must include both parent headers".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn header_text<'a>(headers: &'a HeaderMap, name: &str) -> Result<Option<&'a str>> {
|
||||
headers
|
||||
.get(name)
|
||||
.map(|value| value.to_str())
|
||||
.transpose()
|
||||
.map_err(|error| crate::Error::Protocol(format!("invalid {name} header: {error}")))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn subagent_parent_headers_are_an_atomic_pair() {
|
||||
let mut headers = HeaderMap::new();
|
||||
headers.insert(
|
||||
"x-parent-request-id",
|
||||
HeaderValue::from_static("parent-run"),
|
||||
);
|
||||
assert!(parent_headers(&headers).is_err());
|
||||
|
||||
headers.insert(
|
||||
"x-parent-agent-tool-call-id",
|
||||
HeaderValue::from_static("parent-call"),
|
||||
);
|
||||
assert_eq!(
|
||||
parent_headers(&headers).unwrap(),
|
||||
Some(CursorParent {
|
||||
run_id: "parent-run".into(),
|
||||
tool_call_id: "parent-call".into(),
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,105 @@
|
||||
mod query;
|
||||
mod render;
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{
|
||||
cursor::proto::agent::v1 as pb,
|
||||
model::{ToolCall, Usage},
|
||||
provider::ModelEvent,
|
||||
Result,
|
||||
};
|
||||
|
||||
pub use query::tool_query;
|
||||
pub(crate) use render::{edit_content_delta, edit_path_partial};
|
||||
pub use render::{render_tool_call, tool_completed, tool_placeholder, tool_started};
|
||||
|
||||
pub fn response_event(
|
||||
event: &ModelEvent,
|
||||
model_call_id: &str,
|
||||
) -> Result<Option<pb::AgentServerMessage>> {
|
||||
use pb::interaction_update::Message;
|
||||
let message = match event {
|
||||
ModelEvent::TextDelta(text) => Message::TextDelta(pb::TextDeltaUpdate {
|
||||
text: text.clone(),
|
||||
is_server_notice: false,
|
||||
}),
|
||||
ModelEvent::ThinkingDelta(text) => Message::ThinkingDelta(pb::ThinkingDeltaUpdate {
|
||||
text: text.clone(),
|
||||
thinking_style: Some(pb::ThinkingStyle::Default as i32),
|
||||
}),
|
||||
ModelEvent::ToolCallStart { call_id, name, .. } => {
|
||||
Message::PartialToolCall(pb::PartialToolCallUpdate {
|
||||
call_id: call_id.clone(),
|
||||
tool_call: Some(tool_placeholder(name, call_id)?),
|
||||
args_text_delta: String::new(),
|
||||
model_call_id: model_call_id.into(),
|
||||
})
|
||||
}
|
||||
ModelEvent::ToolCallArgumentsDelta { .. } => return Ok(None),
|
||||
ModelEvent::ToolCallEnd { .. }
|
||||
| ModelEvent::Start { .. }
|
||||
| ModelEvent::TextStart
|
||||
| ModelEvent::TextEnd
|
||||
| ModelEvent::ThinkingStart
|
||||
| ModelEvent::ThinkingEnd
|
||||
| ModelEvent::ProviderReplayState(_)
|
||||
| ModelEvent::Usage(_)
|
||||
| ModelEvent::Done(_) => return Ok(None),
|
||||
};
|
||||
Ok(Some(server_interaction(message)))
|
||||
}
|
||||
|
||||
pub fn thinking_completed(elapsed: Duration) -> pb::AgentServerMessage {
|
||||
let milliseconds = elapsed.as_millis().clamp(1, i32::MAX as u128) as i32;
|
||||
server_interaction(pb::interaction_update::Message::ThinkingCompleted(
|
||||
pb::ThinkingCompletedUpdate {
|
||||
thinking_duration_ms: milliseconds,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn arguments_delta(call: &ToolCall, delta: &str) -> Result<pb::AgentServerMessage> {
|
||||
Ok(server_interaction(
|
||||
pb::interaction_update::Message::PartialToolCall(pb::PartialToolCallUpdate {
|
||||
call_id: call.call_id.clone(),
|
||||
tool_call: Some(tool_placeholder(&call.name, &call.call_id)?),
|
||||
args_text_delta: delta.into(),
|
||||
model_call_id: call.model_call_id.clone(),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn turn_ended(usage: Option<Usage>) -> pb::AgentServerMessage {
|
||||
server_interaction(pb::interaction_update::Message::TurnEnded(
|
||||
pb::TurnEndedUpdate {
|
||||
input_tokens: usage.and_then(|usage| usage.input_tokens.map(|value| value as i64)),
|
||||
output_tokens: usage.and_then(|usage| usage.output_tokens.map(|value| value as i64)),
|
||||
cache_read_tokens: usage
|
||||
.and_then(|usage| usage.cache_read_tokens.map(|value| value as i64)),
|
||||
cache_write_tokens: usage
|
||||
.and_then(|usage| usage.cache_write_tokens.map(|value| value as i64)),
|
||||
reasoning_tokens: usage
|
||||
.and_then(|usage| usage.reasoning_tokens.map(|value| value as i64)),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn token_delta(tokens: u64) -> pb::AgentServerMessage {
|
||||
server_interaction(pb::interaction_update::Message::TokenDelta(
|
||||
pb::TokenDeltaUpdate {
|
||||
tokens: tokens.min(i32::MAX as u64) as i32,
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn server_interaction(message: pb::interaction_update::Message) -> pb::AgentServerMessage {
|
||||
pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::InteractionUpdate(
|
||||
pb::InteractionUpdate {
|
||||
message: Some(message),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{cursor::proto::agent::v1 as pb, model::ToolCall, Error, Result};
|
||||
|
||||
pub fn tool_query(id: u32, call: &ToolCall) -> Result<pb::AgentServerMessage> {
|
||||
use pb::interaction_query::Query;
|
||||
let string = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
|
||||
};
|
||||
let optional_string = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
};
|
||||
let query = match normalized(&call.name).as_str() {
|
||||
"askquestion" => {
|
||||
let questions = call
|
||||
.arguments
|
||||
.get("questions")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|question| -> Result<_> {
|
||||
let required = |name: &str| {
|
||||
question
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| Error::Protocol(format!("question is missing {name}")))
|
||||
};
|
||||
let options = question
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|option| -> Result<_> {
|
||||
let value = |name: &str| {
|
||||
option
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"question option is missing {name}"
|
||||
))
|
||||
})
|
||||
};
|
||||
Ok(pb::ask_question_args::Option {
|
||||
id: value("id")?,
|
||||
label: value("label")?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(pb::ask_question_args::Question {
|
||||
id: required("id")?,
|
||||
prompt: required("prompt")?,
|
||||
options,
|
||||
allow_multiple: question
|
||||
.get("allow_multiple")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Query::AskQuestionInteractionQuery(pb::AskQuestionInteractionQuery {
|
||||
args: Some(pb::AskQuestionArgs {
|
||||
title: optional_string("title").unwrap_or_default(),
|
||||
questions,
|
||||
run_async: false,
|
||||
async_original_tool_call_id: String::new(),
|
||||
}),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
})
|
||||
}
|
||||
"websearch" => Query::WebSearchRequestQuery(pb::WebSearchRequestQuery {
|
||||
args: Some(pb::WebSearchArgs {
|
||||
search_term: string("search_term")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
}),
|
||||
"webfetch" => Query::WebFetchRequestQuery(pb::WebFetchRequestQuery {
|
||||
args: Some(pb::WebFetchArgs {
|
||||
url: string("url")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
skip_approval: false,
|
||||
smart_mode_approval: smart_mode_approval(
|
||||
call,
|
||||
"requestSmartModeApproval",
|
||||
"smartModeBlockReason",
|
||||
)?,
|
||||
}),
|
||||
"switchmode" => Query::SwitchModeRequestQuery(pb::SwitchModeRequestQuery {
|
||||
args: Some(pb::SwitchModeArgs {
|
||||
target_mode_id: string("target_mode_id")?,
|
||||
explanation: optional_string("explanation"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
}),
|
||||
"createplan" => {
|
||||
let todos = call
|
||||
.arguments
|
||||
.get("todos")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|todo| pb::TodoItem {
|
||||
id: todo
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
content: todo
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
status: pb::TodoStatus::Pending as i32,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
dependencies: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
Query::CreatePlanRequestQuery(pb::CreatePlanRequestQuery {
|
||||
args: Some(pb::CreatePlanArgs {
|
||||
plan: string("plan")?,
|
||||
todos,
|
||||
overview: string("overview")?,
|
||||
name: string("name")?,
|
||||
is_project: false,
|
||||
phases: Vec::new(),
|
||||
}),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
})
|
||||
}
|
||||
"generateimage" => Query::GenerateImageRequestQuery(pb::GenerateImageRequestQuery {
|
||||
args: Some(pb::GenerateImageArgs {
|
||||
description: string("description")?,
|
||||
file_path: optional_string("filename"),
|
||||
reference_image_paths: call
|
||||
.arguments
|
||||
.get("reference_image_paths")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
aspect_ratio: optional_string("aspect_ratio"),
|
||||
}),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
other => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"tool {other} is not an InteractionQuery"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::InteractionQuery(
|
||||
pb::InteractionQuery {
|
||||
id,
|
||||
query: Some(query),
|
||||
},
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
fn smart_mode_approval(
|
||||
call: &ToolCall,
|
||||
request_field: &str,
|
||||
reason_field: &str,
|
||||
) -> Result<Option<pb::SmartModeApproval>> {
|
||||
if !call
|
||||
.arguments
|
||||
.get(request_field)
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let reason = call
|
||||
.arguments
|
||||
.get(reason_field)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol(format!("{} requires {reason_field}", call.name)))?;
|
||||
Ok(Some(pb::SmartModeApproval {
|
||||
request_id: call.call_id.clone(),
|
||||
reason: reason.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn normalized(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
+103
-258
@@ -1,69 +1,56 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
cursor::{
|
||||
proto::agent::v1 as pb,
|
||||
tool_result::{self, ToolCompletion},
|
||||
tools::{
|
||||
codec, edit,
|
||||
result::{self as tool_result, ToolCompletion},
|
||||
},
|
||||
},
|
||||
model::{ToolCall, Usage},
|
||||
provider::ResponseEvent,
|
||||
model::ToolCall,
|
||||
Error, Result,
|
||||
};
|
||||
use serde_json::Value;
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::{Error, Result};
|
||||
use super::server_interaction;
|
||||
|
||||
pub fn response_event(
|
||||
event: &ResponseEvent,
|
||||
model_call_id: &str,
|
||||
) -> Result<Option<pb::AgentServerMessage>> {
|
||||
use pb::interaction_update::Message;
|
||||
let message = match event {
|
||||
ResponseEvent::TextDelta(text) => Message::TextDelta(pb::TextDeltaUpdate {
|
||||
text: text.clone(),
|
||||
is_server_notice: false,
|
||||
}),
|
||||
ResponseEvent::ThinkingDelta(text) => Message::ThinkingDelta(pb::ThinkingDeltaUpdate {
|
||||
text: text.clone(),
|
||||
thinking_style: Some(pb::ThinkingStyle::Default as i32),
|
||||
}),
|
||||
ResponseEvent::ToolCallStart { call_id, name, .. } => {
|
||||
Message::PartialToolCall(pb::PartialToolCallUpdate {
|
||||
call_id: call_id.clone(),
|
||||
tool_call: Some(tool_placeholder(name, call_id)?),
|
||||
args_text_delta: String::new(),
|
||||
model_call_id: model_call_id.into(),
|
||||
})
|
||||
}
|
||||
ResponseEvent::ToolCallArgumentsDelta { .. } => return Ok(None),
|
||||
ResponseEvent::ToolCallEnd { .. }
|
||||
| ResponseEvent::Start { .. }
|
||||
| ResponseEvent::TextStart
|
||||
| ResponseEvent::TextEnd
|
||||
| ResponseEvent::ThinkingStart
|
||||
| ResponseEvent::ThinkingEnd
|
||||
| ResponseEvent::Usage(_)
|
||||
| ResponseEvent::Done(_) => return Ok(None),
|
||||
};
|
||||
Ok(Some(server_interaction(message)))
|
||||
}
|
||||
|
||||
pub fn thinking_completed(elapsed: Duration) -> pb::AgentServerMessage {
|
||||
let milliseconds = elapsed.as_millis().clamp(1, i32::MAX as u128) as i32;
|
||||
server_interaction(pb::interaction_update::Message::ThinkingCompleted(
|
||||
pb::ThinkingCompletedUpdate {
|
||||
thinking_duration_ms: milliseconds,
|
||||
pub(crate) fn edit_path_partial(call: &ToolCall, path: &str) -> pb::AgentServerMessage {
|
||||
server_interaction(pb::interaction_update::Message::PartialToolCall(
|
||||
pb::PartialToolCallUpdate {
|
||||
call_id: call.call_id.clone(),
|
||||
tool_call: Some(pb::ToolCall {
|
||||
hook_additional_contexts: Vec::new(),
|
||||
tool_call_id: Some(call.call_id.clone()),
|
||||
started_at_ms: None,
|
||||
completed_at_ms: None,
|
||||
tool: Some(pb::tool_call::Tool::EditToolCall(pb::EditToolCall {
|
||||
args: Some(pb::EditArgs {
|
||||
path: path.into(),
|
||||
stream_content: None,
|
||||
}),
|
||||
result: None,
|
||||
})),
|
||||
}),
|
||||
args_text_delta: String::new(),
|
||||
model_call_id: call.model_call_id.clone(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn arguments_delta(call: &ToolCall, delta: &str) -> Result<pb::AgentServerMessage> {
|
||||
Ok(server_interaction(
|
||||
pb::interaction_update::Message::PartialToolCall(pb::PartialToolCallUpdate {
|
||||
pub(crate) fn edit_content_delta(call: &ToolCall, content: String) -> pb::AgentServerMessage {
|
||||
server_interaction(pb::interaction_update::Message::ToolCallDelta(Box::new(
|
||||
pb::ToolCallDeltaUpdate {
|
||||
call_id: call.call_id.clone(),
|
||||
tool_call: Some(tool_placeholder(&call.name, &call.call_id)?),
|
||||
args_text_delta: delta.into(),
|
||||
tool_call_delta: Some(Box::new(pb::ToolCallDelta {
|
||||
delta: Some(pb::tool_call_delta::Delta::EditToolCallDelta(
|
||||
pb::EditToolCallDelta {
|
||||
stream_content_delta: content,
|
||||
},
|
||||
)),
|
||||
})),
|
||||
model_call_id: call.model_call_id.clone(),
|
||||
}),
|
||||
))
|
||||
},
|
||||
)))
|
||||
}
|
||||
|
||||
pub fn tool_started(call: &ToolCall) -> Result<pb::AgentServerMessage> {
|
||||
@@ -86,198 +73,16 @@ pub fn tool_completed(call: &ToolCall, completion: &ToolCompletion) -> pb::Agent
|
||||
))
|
||||
}
|
||||
|
||||
pub fn turn_ended(usage: Usage) -> pb::AgentServerMessage {
|
||||
server_interaction(pb::interaction_update::Message::TurnEnded(
|
||||
pb::TurnEndedUpdate {
|
||||
input_tokens: Some(usage.input_tokens as i64),
|
||||
output_tokens: Some(usage.output_tokens as i64),
|
||||
cache_read_tokens: Some(usage.cache_read_tokens as i64),
|
||||
cache_write_tokens: Some(usage.cache_write_tokens as i64),
|
||||
reasoning_tokens: Some(usage.reasoning_tokens as i64),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
pub fn tool_query(id: u32, call: &ToolCall) -> Result<pb::AgentServerMessage> {
|
||||
use pb::interaction_query::Query;
|
||||
let string = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
|
||||
};
|
||||
let optional_string = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
};
|
||||
let query = match normalized(&call.name).as_str() {
|
||||
"askquestion" => {
|
||||
let questions = call
|
||||
.arguments
|
||||
.get("questions")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|question| -> Result<_> {
|
||||
let required = |name: &str| {
|
||||
question
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| Error::Protocol(format!("question is missing {name}")))
|
||||
};
|
||||
let options = question
|
||||
.get("options")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|option| -> Result<_> {
|
||||
let value = |name: &str| {
|
||||
option
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"question option is missing {name}"
|
||||
))
|
||||
})
|
||||
};
|
||||
Ok(pb::ask_question_args::Option {
|
||||
id: value("id")?,
|
||||
label: value("label")?,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(pb::ask_question_args::Question {
|
||||
id: required("id")?,
|
||||
prompt: required("prompt")?,
|
||||
options,
|
||||
allow_multiple: question
|
||||
.get("allow_multiple")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Query::AskQuestionInteractionQuery(pb::AskQuestionInteractionQuery {
|
||||
args: Some(pb::AskQuestionArgs {
|
||||
title: optional_string("title").unwrap_or_default(),
|
||||
questions,
|
||||
run_async: false,
|
||||
async_original_tool_call_id: String::new(),
|
||||
}),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
})
|
||||
}
|
||||
"websearch" => Query::WebSearchRequestQuery(pb::WebSearchRequestQuery {
|
||||
args: Some(pb::WebSearchArgs {
|
||||
search_term: string("search_term")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
}),
|
||||
"webfetch" => Query::WebFetchRequestQuery(pb::WebFetchRequestQuery {
|
||||
args: Some(pb::WebFetchArgs {
|
||||
url: string("url")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
skip_approval: false,
|
||||
smart_mode_approval: None,
|
||||
}),
|
||||
"switchmode" => Query::SwitchModeRequestQuery(pb::SwitchModeRequestQuery {
|
||||
args: Some(pb::SwitchModeArgs {
|
||||
target_mode_id: string("target_mode_id")?,
|
||||
explanation: optional_string("explanation"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
}),
|
||||
"createplan" => {
|
||||
let todos = call
|
||||
.arguments
|
||||
.get("todos")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.map(|todo| pb::TodoItem {
|
||||
id: todo
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
content: todo
|
||||
.get("content")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
status: pb::TodoStatus::Pending as i32,
|
||||
created_at: 0,
|
||||
updated_at: 0,
|
||||
dependencies: Vec::new(),
|
||||
})
|
||||
.collect();
|
||||
Query::CreatePlanRequestQuery(pb::CreatePlanRequestQuery {
|
||||
args: Some(pb::CreatePlanArgs {
|
||||
plan: string("plan")?,
|
||||
todos,
|
||||
overview: string("overview")?,
|
||||
name: string("name")?,
|
||||
is_project: false,
|
||||
phases: Vec::new(),
|
||||
}),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
})
|
||||
}
|
||||
"generateimage" => Query::GenerateImageRequestQuery(pb::GenerateImageRequestQuery {
|
||||
args: Some(pb::GenerateImageArgs {
|
||||
description: optional_string("description").unwrap_or_default(),
|
||||
file_path: optional_string("file_path"),
|
||||
reference_image_paths: Vec::new(),
|
||||
aspect_ratio: optional_string("aspect_ratio"),
|
||||
}),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
other => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"tool {other} is not an InteractionQuery"
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok(pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::InteractionQuery(
|
||||
pb::InteractionQuery {
|
||||
id,
|
||||
query: Some(query),
|
||||
},
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
pub fn server_interaction(message: pb::interaction_update::Message) -> pb::AgentServerMessage {
|
||||
pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::InteractionUpdate(
|
||||
pb::InteractionUpdate {
|
||||
message: Some(message),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tool_placeholder(name: &str, call_id: &str) -> Result<pb::ToolCall> {
|
||||
use pb::tool_call::Tool;
|
||||
let tool = match normalized(name).as_str() {
|
||||
"shell" | "forcebackgroundshell" => Tool::ShellToolCall(pb::ShellToolCall::default()),
|
||||
"shell" => Tool::ShellToolCall(pb::ShellToolCall::default()),
|
||||
"delete" => Tool::DeleteToolCall(pb::DeleteToolCall::default()),
|
||||
"glob" => Tool::GlobToolCall(pb::GlobToolCall::default()),
|
||||
"grep" => Tool::GrepToolCall(pb::GrepToolCall::default()),
|
||||
"read" => Tool::ReadToolCall(pb::ReadToolCall::default()),
|
||||
"todowrite" => Tool::UpdateTodosToolCall(pb::UpdateTodosToolCall::default()),
|
||||
"patchedit" | "write" => Tool::EditToolCall(pb::EditToolCall::default()),
|
||||
"ls" => Tool::LsToolCall(pb::LsToolCall::default()),
|
||||
"strreplace" | "editnotebook" | "write" => Tool::EditToolCall(pb::EditToolCall::default()),
|
||||
"readlints" => Tool::ReadLintsToolCall(pb::ReadLintsToolCall::default()),
|
||||
"callmcptool" => Tool::McpToolCall(pb::McpToolCall::default()),
|
||||
"createplan" => Tool::CreatePlanToolCall(pb::CreatePlanToolCall::default()),
|
||||
@@ -288,10 +93,11 @@ pub fn tool_placeholder(name: &str, call_id: &str) -> Result<pb::ToolCall> {
|
||||
"webfetch" => Tool::WebFetchToolCall(pb::WebFetchToolCall::default()),
|
||||
"switchmode" => Tool::SwitchModeToolCall(pb::SwitchModeToolCall::default()),
|
||||
"generateimage" => Tool::GenerateImageToolCall(pb::GenerateImageToolCall::default()),
|
||||
"communicateupdate" => {
|
||||
"updatecurrentstep" => {
|
||||
Tool::CommunicateUpdateToolCall(pb::CommunicateUpdateToolCall::default())
|
||||
}
|
||||
"writeshellstdin" => Tool::WriteShellStdinToolCall(pb::WriteShellStdinToolCall::default()),
|
||||
"awaitshell" => Tool::AwaitToolCall(pb::AwaitToolCall::default()),
|
||||
"getmcptools" => Tool::GetMcpToolsToolCall(pb::GetMcpToolsToolCall::default()),
|
||||
_ => return Err(Error::Protocol(format!("unsupported tool: {name}"))),
|
||||
};
|
||||
Ok(pb::ToolCall {
|
||||
@@ -387,18 +193,15 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
||||
let stream_content = if normalized(&call.name) == "write" {
|
||||
optional("contents").unwrap_or_default()
|
||||
} else {
|
||||
format!("{}\n---\n{}", string("old_string"), string("new_string"))
|
||||
optional("new_string").unwrap_or_default()
|
||||
};
|
||||
tool.args = Some(pb::EditArgs {
|
||||
path: string("path"),
|
||||
stream_content: Some(stream_content),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::LsToolCall(tool)) => {
|
||||
tool.args = Some(pb::LsArgs {
|
||||
path: string("path"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
..Default::default()
|
||||
path: if normalized(&call.name) == "editnotebook" {
|
||||
string("target_notebook")
|
||||
} else {
|
||||
string("path")
|
||||
},
|
||||
stream_content: Some(edit::normalize_newlines(&stream_content)),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::ReadLintsToolCall(tool)) => {
|
||||
@@ -421,7 +224,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
||||
.arguments
|
||||
.get("arguments")
|
||||
.and_then(Value::as_object)
|
||||
.map(super::exec::json_object_to_prost)
|
||||
.map(codec::json_object_to_prost)
|
||||
.unwrap_or_default(),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
tool_name: optional("toolName").unwrap_or_default(),
|
||||
@@ -453,10 +256,18 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
||||
model: optional("model"),
|
||||
resume: optional("resume"),
|
||||
agent_id: None,
|
||||
attachments: Vec::new(),
|
||||
attachments: call
|
||||
.arguments
|
||||
.get("file_attachments")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
mode: 0,
|
||||
responding_to_message_ids: Vec::new(),
|
||||
environment: 0,
|
||||
environment: execution_environment(optional("environment").as_deref()),
|
||||
machine: None,
|
||||
})
|
||||
}
|
||||
@@ -485,8 +296,16 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
||||
Some(pb::tool_call::Tool::GenerateImageToolCall(tool)) => {
|
||||
tool.args = Some(pb::GenerateImageArgs {
|
||||
description: string("description"),
|
||||
file_path: optional("file_path"),
|
||||
reference_image_paths: Vec::new(),
|
||||
file_path: optional("filename"),
|
||||
reference_image_paths: call
|
||||
.arguments
|
||||
.get("reference_image_paths")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
.filter_map(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.collect(),
|
||||
aspect_ratio: optional("aspect_ratio"),
|
||||
})
|
||||
}
|
||||
@@ -507,6 +326,25 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
||||
chars: string("chars"),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::AwaitToolCall(tool)) => {
|
||||
tool.args = Some(pb::AwaitArgs {
|
||||
task_id: string("shell_id"),
|
||||
block_until_ms: call
|
||||
.arguments
|
||||
.get("block_until_ms")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|v| v as u32),
|
||||
regex: optional("pattern"),
|
||||
})
|
||||
}
|
||||
Some(pb::tool_call::Tool::GetMcpToolsToolCall(tool)) => {
|
||||
tool.args = Some(pb::GetMcpToolsArgs {
|
||||
server: optional("server"),
|
||||
tool_name: optional("toolName"),
|
||||
pattern: optional("pattern"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
})
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
Ok(output)
|
||||
@@ -515,22 +353,29 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
|
||||
fn subagent_type(name: &str) -> pb::SubagentType {
|
||||
use pb::subagent_type::Type;
|
||||
let r#type = match name.to_ascii_lowercase().as_str() {
|
||||
"" | "generalpurpose" => Type::Unspecified(pb::SubagentTypeUnspecified {}),
|
||||
"explore" => Type::Explore(pb::SubagentTypeExplore {}),
|
||||
"browser-use" | "browseruse" => Type::BrowserUse(pb::SubagentTypeBrowserUse {}),
|
||||
"shell" => Type::Shell(pb::SubagentTypeShell {}),
|
||||
"bash" => Type::Bash(pb::SubagentTypeBash {}),
|
||||
"debug" => Type::Debug(pb::SubagentTypeDebug {}),
|
||||
"cursor-guide" | "cursorguide" => Type::CursorGuide(pb::SubagentTypeCursorGuide {}),
|
||||
"computer-use" | "computeruse" => Type::ComputerUse(pb::SubagentTypeComputerUse {}),
|
||||
"" => Type::Unspecified(pb::SubagentTypeUnspecified {}),
|
||||
custom => Type::Custom(pb::SubagentTypeCustom {
|
||||
name: custom.into(),
|
||||
}),
|
||||
_ => Type::Custom(pb::SubagentTypeCustom { name: name.into() }),
|
||||
};
|
||||
pb::SubagentType {
|
||||
r#type: Some(r#type),
|
||||
}
|
||||
}
|
||||
|
||||
fn execution_environment(value: Option<&str>) -> i32 {
|
||||
match value {
|
||||
Some("cloud") => pb::SubagentExecutionEnvironment::Cloud as i32,
|
||||
Some("local") | None => pb::SubagentExecutionEnvironment::Local as i32,
|
||||
Some(_) => pb::SubagentExecutionEnvironment::Unspecified as i32,
|
||||
}
|
||||
}
|
||||
|
||||
fn normalized(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
@@ -0,0 +1,269 @@
|
||||
use crate::{Error, Result};
|
||||
|
||||
#[derive(Debug, PartialEq)]
|
||||
pub(crate) enum StringFieldEvent {
|
||||
Delta { name: String, text: String },
|
||||
End { name: String },
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(crate) struct JsonStringFields {
|
||||
state: State,
|
||||
key: String,
|
||||
string: JsonString,
|
||||
skipped: SkippedValue,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
enum State {
|
||||
#[default]
|
||||
Object,
|
||||
Key,
|
||||
KeyString,
|
||||
Colon,
|
||||
Value,
|
||||
ValueString,
|
||||
SkipValue,
|
||||
AfterValue,
|
||||
Done,
|
||||
}
|
||||
|
||||
impl JsonStringFields {
|
||||
pub fn push(&mut self, input: &str) -> Result<Vec<StringFieldEvent>> {
|
||||
let mut events = Vec::new();
|
||||
for character in input.chars() {
|
||||
self.consume(character, &mut events)?;
|
||||
}
|
||||
Ok(events)
|
||||
}
|
||||
|
||||
fn consume(&mut self, character: char, events: &mut Vec<StringFieldEvent>) -> Result<()> {
|
||||
match self.state {
|
||||
State::Object => match character {
|
||||
'{' => self.state = State::Key,
|
||||
value if value.is_whitespace() => {}
|
||||
_ => return Err(protocol("tool arguments must start with an object")),
|
||||
},
|
||||
State::Key => match character {
|
||||
'"' => {
|
||||
self.key.clear();
|
||||
self.string.clear();
|
||||
self.state = State::KeyString;
|
||||
}
|
||||
'}' => self.state = State::Done,
|
||||
value if value.is_whitespace() => {}
|
||||
_ => return Err(protocol("expected a tool argument name")),
|
||||
},
|
||||
State::KeyString => match self.string.push(character)? {
|
||||
StringStep::Text(text) => self.key.push_str(&text),
|
||||
StringStep::End => self.state = State::Colon,
|
||||
StringStep::Pending => {}
|
||||
},
|
||||
State::Colon => match character {
|
||||
':' => self.state = State::Value,
|
||||
value if value.is_whitespace() => {}
|
||||
_ => return Err(protocol("expected ':' after tool argument name")),
|
||||
},
|
||||
State::Value => match character {
|
||||
'"' => {
|
||||
self.string.clear();
|
||||
self.state = State::ValueString;
|
||||
}
|
||||
value if value.is_whitespace() => {}
|
||||
value => {
|
||||
self.skipped.start(value);
|
||||
self.state = State::SkipValue;
|
||||
}
|
||||
},
|
||||
State::ValueString => match self.string.push(character)? {
|
||||
StringStep::Text(text) => push_delta(events, &self.key, text),
|
||||
StringStep::End => {
|
||||
events.push(StringFieldEvent::End {
|
||||
name: self.key.clone(),
|
||||
});
|
||||
self.state = State::AfterValue;
|
||||
}
|
||||
StringStep::Pending => {}
|
||||
},
|
||||
State::SkipValue => {
|
||||
if let Some(terminal) = self.skipped.push(character) {
|
||||
self.state = match terminal {
|
||||
',' => State::Key,
|
||||
'}' => State::Done,
|
||||
_ => return Err(protocol("invalid skipped JSON value terminator")),
|
||||
};
|
||||
}
|
||||
}
|
||||
State::AfterValue => match character {
|
||||
',' => self.state = State::Key,
|
||||
'}' => self.state = State::Done,
|
||||
value if value.is_whitespace() => {}
|
||||
_ => return Err(protocol("expected ',' after tool argument value")),
|
||||
},
|
||||
State::Done if character.is_whitespace() => {}
|
||||
State::Done => return Err(protocol("data after tool arguments object")),
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
fn push_delta(events: &mut Vec<StringFieldEvent>, name: &str, text: String) {
|
||||
if let Some(StringFieldEvent::Delta {
|
||||
name: previous_name,
|
||||
text: previous_text,
|
||||
}) = events.last_mut()
|
||||
{
|
||||
if previous_name == name {
|
||||
previous_text.push_str(&text);
|
||||
return;
|
||||
}
|
||||
}
|
||||
events.push(StringFieldEvent::Delta {
|
||||
name: name.into(),
|
||||
text,
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct JsonString {
|
||||
escape: String,
|
||||
}
|
||||
|
||||
enum StringStep {
|
||||
Text(String),
|
||||
End,
|
||||
Pending,
|
||||
}
|
||||
|
||||
impl JsonString {
|
||||
fn clear(&mut self) {
|
||||
self.escape.clear();
|
||||
}
|
||||
|
||||
fn push(&mut self, character: char) -> Result<StringStep> {
|
||||
if self.escape.is_empty() {
|
||||
return match character {
|
||||
'"' => Ok(StringStep::End),
|
||||
'\\' => {
|
||||
self.escape.push(character);
|
||||
Ok(StringStep::Pending)
|
||||
}
|
||||
value if value < '\u{20}' => Err(protocol("control character in JSON string")),
|
||||
value => Ok(StringStep::Text(value.to_string())),
|
||||
};
|
||||
}
|
||||
|
||||
self.escape.push(character);
|
||||
let complete = match self.escape.as_bytes() {
|
||||
[b'\\', b'u', a, b, c, d]
|
||||
if [a, b, c, d].iter().all(|value| value.is_ascii_hexdigit()) =>
|
||||
{
|
||||
let code = u16::from_str_radix(&self.escape[2..], 16)
|
||||
.map_err(|_| protocol("invalid JSON unicode escape"))?;
|
||||
!(0xD800..=0xDBFF).contains(&code)
|
||||
}
|
||||
[b'\\', b'u', ..] if self.escape.len() < 6 => false,
|
||||
[b'\\', b'u', a, b, c, d, b'\\', b'u', e, f, g, h]
|
||||
if [a, b, c, d, e, f, g, h]
|
||||
.iter()
|
||||
.all(|value| value.is_ascii_hexdigit()) =>
|
||||
{
|
||||
true
|
||||
}
|
||||
[b'\\', b'u', ..] if self.escape.len() < 12 => false,
|
||||
[b'\\', b'"' | b'\\' | b'/' | b'b' | b'f' | b'n' | b'r' | b't'] => true,
|
||||
[b'\\'] => false,
|
||||
_ => return Err(protocol("invalid JSON string escape")),
|
||||
};
|
||||
if !complete {
|
||||
return Ok(StringStep::Pending);
|
||||
}
|
||||
let quoted = format!("\"{}\"", self.escape);
|
||||
let decoded: String = serde_json::from_str("ed)
|
||||
.map_err(|error| protocol(&format!("invalid JSON string escape: {error}")))?;
|
||||
self.escape.clear();
|
||||
Ok(StringStep::Text(decoded))
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct SkippedValue {
|
||||
depth: usize,
|
||||
string: bool,
|
||||
escaped: bool,
|
||||
}
|
||||
|
||||
impl SkippedValue {
|
||||
fn start(&mut self, first: char) {
|
||||
*self = Self::default();
|
||||
self.observe(first);
|
||||
}
|
||||
|
||||
fn push(&mut self, character: char) -> Option<char> {
|
||||
if !self.string && self.depth == 0 && matches!(character, ',' | '}') {
|
||||
return Some(character);
|
||||
}
|
||||
self.observe(character);
|
||||
None
|
||||
}
|
||||
|
||||
fn observe(&mut self, character: char) {
|
||||
if self.string {
|
||||
if self.escaped {
|
||||
self.escaped = false;
|
||||
} else if character == '\\' {
|
||||
self.escaped = true;
|
||||
} else if character == '"' {
|
||||
self.string = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
match character {
|
||||
'"' => self.string = true,
|
||||
'{' | '[' => self.depth += 1,
|
||||
'}' | ']' => self.depth = self.depth.saturating_sub(1),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn protocol(message: &str) -> Error {
|
||||
Error::Protocol(message.into())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn streams_top_level_strings_and_decodes_split_escapes() {
|
||||
let mut fields = JsonStringFields::default();
|
||||
let mut events = fields
|
||||
.push("{\"path\":\"/tmp/a\",\"count\":1,\"contents\":\"a\\n\\uD8")
|
||||
.unwrap();
|
||||
events.extend(fields.push("3D\\uDE00b\"}").unwrap());
|
||||
assert_eq!(
|
||||
events,
|
||||
vec![
|
||||
StringFieldEvent::Delta {
|
||||
name: "path".into(),
|
||||
text: "/tmp/a".into()
|
||||
},
|
||||
StringFieldEvent::End {
|
||||
name: "path".into()
|
||||
},
|
||||
StringFieldEvent::Delta {
|
||||
name: "contents".into(),
|
||||
text: "a\n".into()
|
||||
},
|
||||
StringFieldEvent::Delta {
|
||||
name: "contents".into(),
|
||||
text: "😀b".into()
|
||||
},
|
||||
StringFieldEvent::End {
|
||||
name: "contents".into()
|
||||
},
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,81 @@
|
||||
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine};
|
||||
use prost::Message;
|
||||
|
||||
use crate::{
|
||||
cursor::CursorSessionHandle,
|
||||
cursor::{
|
||||
connect::{
|
||||
encode_end_stream, encode_error_end_stream, ConnectCode, ConnectErrorDetail,
|
||||
ConnectStreamError,
|
||||
},
|
||||
proto::aiserver::v1 as ai,
|
||||
},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
pub fn finish_success(handle: &CursorSessionHandle) {
|
||||
handle.emit_frame(encode_end_stream());
|
||||
handle.close_output();
|
||||
}
|
||||
|
||||
pub fn fail(handle: &CursorSessionHandle, error: &Error) -> Result<()> {
|
||||
let stream_error = match error {
|
||||
Error::Provider(_) | Error::Http(_) => provider_error(error),
|
||||
Error::Protocol(_) | Error::Decode(_) | Error::Json(_) => {
|
||||
plain_error(ConnectCode::InvalidArgument, error)
|
||||
}
|
||||
Error::RunNotFound(_) => plain_error(ConnectCode::NotFound, error),
|
||||
Error::Cancelled => plain_error(ConnectCode::Canceled, error),
|
||||
Error::Config(_)
|
||||
| Error::Store(_)
|
||||
| Error::Database(_)
|
||||
| Error::Migration(_)
|
||||
| Error::Encode(_)
|
||||
| Error::Io(_) => plain_error(ConnectCode::Internal, error),
|
||||
};
|
||||
handle.emit_frame(encode_error_end_stream(&stream_error)?);
|
||||
handle.close_output();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub fn cancel(handle: &CursorSessionHandle) -> Result<()> {
|
||||
handle.emit_frame(encode_error_end_stream(&ConnectStreamError {
|
||||
code: ConnectCode::Canceled,
|
||||
message: "run was cancelled".into(),
|
||||
details: Vec::new(),
|
||||
})?);
|
||||
handle.close_output();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn plain_error(code: ConnectCode, error: &Error) -> ConnectStreamError {
|
||||
ConnectStreamError {
|
||||
code,
|
||||
message: error.to_string(),
|
||||
details: Vec::new(),
|
||||
}
|
||||
}
|
||||
|
||||
fn provider_error(error: &Error) -> ConnectStreamError {
|
||||
let detail = ai::ErrorDetails {
|
||||
error: ai::error_details::Error::ProviderError as i32,
|
||||
details: Some(ai::CustomErrorDetails {
|
||||
title: "Server Error".into(),
|
||||
detail: error.to_string(),
|
||||
allow_command_links_potentially_unsafe_please_only_use_for_handwritten_trusted_markdown:
|
||||
Some(true),
|
||||
is_retryable: Some(true),
|
||||
show_request_id: Some(true),
|
||||
should_show_immediate_error: Some(false),
|
||||
}),
|
||||
is_expected: Some(false),
|
||||
};
|
||||
ConnectStreamError {
|
||||
code: ConnectCode::Unavailable,
|
||||
message: error.to_string(),
|
||||
details: vec![ConnectErrorDetail {
|
||||
type_name: "aiserver.v1.ErrorDetails".into(),
|
||||
value: STANDARD_NO_PAD.encode(detail.encode_to_vec()),
|
||||
}],
|
||||
}
|
||||
}
|
||||
@@ -1,12 +1,26 @@
|
||||
mod actor;
|
||||
pub mod bidi_append;
|
||||
pub mod blob_sync;
|
||||
pub mod checkpoint;
|
||||
pub mod connect;
|
||||
pub mod exec;
|
||||
pub mod handlers;
|
||||
mod inbox;
|
||||
pub mod interaction;
|
||||
pub mod pending;
|
||||
mod json_stream;
|
||||
pub(crate) mod lifecycle;
|
||||
mod model_catalog;
|
||||
mod presentation;
|
||||
mod projection;
|
||||
pub mod prompting;
|
||||
pub mod proto;
|
||||
pub mod proxy;
|
||||
pub mod request;
|
||||
pub mod run_sse;
|
||||
pub mod tool_result;
|
||||
pub mod session;
|
||||
pub mod sessions;
|
||||
pub mod tools;
|
||||
mod usage;
|
||||
|
||||
pub use command::CursorCommand;
|
||||
pub use sessions::{CursorParent, CursorSessionHandle, CursorSessionRegistry};
|
||||
mod command;
|
||||
|
||||
@@ -0,0 +1,202 @@
|
||||
use axum::{
|
||||
body::{Body, Bytes},
|
||||
extract::{Extension, State},
|
||||
http::{Request, Response},
|
||||
};
|
||||
use bytes::{BufMut, BytesMut};
|
||||
use prost::Message;
|
||||
|
||||
use crate::{
|
||||
cursor::{
|
||||
proto::agent::v1 as agent,
|
||||
proxy::{self, CursorProxy},
|
||||
CursorSessionRegistry,
|
||||
},
|
||||
model::ProviderModel,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
struct AvailableModelsAddition {
|
||||
#[prost(string, repeated, tag = "1")]
|
||||
model_names: Vec<String>,
|
||||
#[prost(message, repeated, tag = "2")]
|
||||
models: Vec<AvailableModel>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
struct AvailableModel {
|
||||
#[prost(string, tag = "1")]
|
||||
name: String,
|
||||
#[prost(bool, optional, tag = "5")]
|
||||
supports_agent: Option<bool>,
|
||||
#[prost(bool, optional, tag = "9")]
|
||||
supports_thinking: Option<bool>,
|
||||
#[prost(int32, optional, tag = "15")]
|
||||
context_token_limit: Option<i32>,
|
||||
#[prost(string, optional, tag = "17")]
|
||||
client_display_name: Option<String>,
|
||||
#[prost(string, optional, tag = "18")]
|
||||
server_model_name: Option<String>,
|
||||
#[prost(bool, optional, tag = "23")]
|
||||
is_user_added: Option<bool>,
|
||||
#[prost(string, optional, tag = "24")]
|
||||
inputbox_short_model_name: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, PartialEq, Message)]
|
||||
struct UsableModelsAddition {
|
||||
#[prost(message, repeated, tag = "1")]
|
||||
models: Vec<agent::ModelDetails>,
|
||||
}
|
||||
|
||||
pub async fn available_models(
|
||||
State(registry): State<CursorSessionRegistry>,
|
||||
Extension(proxy): Extension<CursorProxy>,
|
||||
request: Request<Body>,
|
||||
) -> Result<Response<Body>> {
|
||||
let models = registry.store().provider_models(true).await?;
|
||||
merge_response(
|
||||
proxy::forward_buffered(&proxy, request).await?,
|
||||
AvailableModelsAddition {
|
||||
model_names: models
|
||||
.iter()
|
||||
.map(|model| model.model_hash.clone())
|
||||
.collect(),
|
||||
models: models.iter().map(available_model).collect(),
|
||||
}
|
||||
.encode_to_vec(),
|
||||
)
|
||||
}
|
||||
|
||||
pub async fn usable_models(
|
||||
State(registry): State<CursorSessionRegistry>,
|
||||
Extension(proxy): Extension<CursorProxy>,
|
||||
request: Request<Body>,
|
||||
) -> Result<Response<Body>> {
|
||||
let models = registry.store().provider_models(true).await?;
|
||||
merge_response(
|
||||
proxy::forward_buffered(&proxy, request).await?,
|
||||
UsableModelsAddition {
|
||||
models: models.iter().map(usable_model).collect(),
|
||||
}
|
||||
.encode_to_vec(),
|
||||
)
|
||||
}
|
||||
|
||||
fn merge_response(upstream: proxy::BufferedResponse, extra: Vec<u8>) -> Result<Response<Body>> {
|
||||
if !upstream.status.is_success() {
|
||||
return Ok(upstream.into_response());
|
||||
}
|
||||
let (framed, payload) = unary_payload(&upstream.body)?;
|
||||
let body = if framed {
|
||||
let mut merged = BytesMut::with_capacity(5 + payload.len() + extra.len());
|
||||
merged.put_u8(0);
|
||||
merged.put_u32((payload.len() + extra.len()) as u32);
|
||||
merged.extend_from_slice(payload);
|
||||
merged.extend_from_slice(&extra);
|
||||
merged.freeze()
|
||||
} else {
|
||||
let mut merged = BytesMut::with_capacity(payload.len() + extra.len());
|
||||
merged.extend_from_slice(payload);
|
||||
merged.extend_from_slice(&extra);
|
||||
merged.freeze()
|
||||
};
|
||||
Ok(upstream.with_body(body))
|
||||
}
|
||||
|
||||
fn unary_payload(body: &Bytes) -> Result<(bool, &[u8])> {
|
||||
if body.len() < 5 {
|
||||
return Ok((false, body));
|
||||
}
|
||||
let flags = body[0];
|
||||
let length = u32::from_be_bytes([body[1], body[2], body[3], body[4]]) as usize;
|
||||
if length != body.len() - 5 {
|
||||
return Ok((false, body));
|
||||
}
|
||||
if flags != 0 {
|
||||
return Err(Error::Protocol(format!(
|
||||
"cannot merge compressed or terminal model catalog frame: flags={flags}"
|
||||
)));
|
||||
}
|
||||
Ok((true, &body[5..]))
|
||||
}
|
||||
|
||||
fn available_model(model: &ProviderModel) -> AvailableModel {
|
||||
AvailableModel {
|
||||
name: model.model_hash.clone(),
|
||||
supports_agent: Some(true),
|
||||
supports_thinking: Some(model.reasoning_enabled),
|
||||
context_token_limit: model
|
||||
.context_window_tokens
|
||||
.map(|value| value.min(i32::MAX as u64) as i32),
|
||||
client_display_name: Some(model.display_name.clone()),
|
||||
server_model_name: Some(model.model_hash.clone()),
|
||||
is_user_added: Some(true),
|
||||
inputbox_short_model_name: Some(model.display_name.clone()),
|
||||
}
|
||||
}
|
||||
|
||||
fn usable_model(model: &ProviderModel) -> agent::ModelDetails {
|
||||
agent::ModelDetails {
|
||||
model_id: model.model_hash.clone(),
|
||||
display_model_id: model.model_hash.clone(),
|
||||
display_name: model.display_name.clone(),
|
||||
display_name_short: model.display_name.clone(),
|
||||
thinking_details: model
|
||||
.reasoning_enabled
|
||||
.then_some(agent::ThinkingDetails::default()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::body::{to_bytes, Bytes};
|
||||
|
||||
use super::*;
|
||||
|
||||
#[tokio::test]
|
||||
async fn appends_models_without_reencoding_official_fields() {
|
||||
// Unknown field 99 = 7 stands in for every official field this service does not know.
|
||||
let official = Bytes::from_static(&[0x98, 0x06, 0x07]);
|
||||
let addition = AvailableModelsAddition {
|
||||
model_names: vec!["f246010a".into()],
|
||||
models: Vec::new(),
|
||||
}
|
||||
.encode_to_vec();
|
||||
let response = merge_response(
|
||||
proxy::BufferedResponse {
|
||||
status: axum::http::StatusCode::OK,
|
||||
headers: Default::default(),
|
||||
body: official.clone(),
|
||||
},
|
||||
addition.clone(),
|
||||
)
|
||||
.unwrap();
|
||||
let merged = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(&merged[..official.len()], official.as_ref());
|
||||
assert_eq!(&merged[official.len()..], addition);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn updates_connect_length_when_catalog_is_framed() {
|
||||
let official = [0x98, 0x06, 0x07];
|
||||
let mut framed = BytesMut::new();
|
||||
framed.put_u8(0);
|
||||
framed.put_u32(official.len() as u32);
|
||||
framed.extend_from_slice(&official);
|
||||
let response = merge_response(
|
||||
proxy::BufferedResponse {
|
||||
status: axum::http::StatusCode::OK,
|
||||
headers: Default::default(),
|
||||
body: framed.freeze(),
|
||||
},
|
||||
vec![0x0a, 0x01, b'x'],
|
||||
)
|
||||
.unwrap();
|
||||
let merged = to_bytes(response.into_body(), usize::MAX).await.unwrap();
|
||||
assert_eq!(u32::from_be_bytes(merged[1..5].try_into().unwrap()), 6);
|
||||
assert_eq!(&merged[5..8], &official);
|
||||
}
|
||||
}
|
||||
@@ -1,139 +0,0 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{
|
||||
atomic::{AtomicU32, Ordering},
|
||||
Arc,
|
||||
},
|
||||
};
|
||||
|
||||
use tokio::sync::Mutex;
|
||||
|
||||
use crate::{model::ToolCall, Error, Result};
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PendingExecRegistry {
|
||||
next_id: Arc<AtomicU32>,
|
||||
entries: Arc<Mutex<HashMap<u32, PendingExec>>>,
|
||||
}
|
||||
|
||||
pub(crate) struct PendingExec {
|
||||
pub call: ToolCall,
|
||||
pub context: ExecContext,
|
||||
pub started_at_ms: u64,
|
||||
pub stdout: String,
|
||||
pub stderr: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct ExecContext {
|
||||
pub conversation_id: String,
|
||||
pub terminals_folder: String,
|
||||
pub admin_command_denylist: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Default)]
|
||||
pub struct PendingClientTools {
|
||||
next_id: Arc<AtomicU32>,
|
||||
calls: Arc<Mutex<HashMap<u32, PendingClientTool>>>,
|
||||
}
|
||||
|
||||
pub(crate) struct PendingClientTool {
|
||||
pub call: ToolCall,
|
||||
pub context: ExecContext,
|
||||
pub started_at_ms: u64,
|
||||
}
|
||||
|
||||
impl PendingExecRegistry {
|
||||
pub async fn reserve(&self, call: &ToolCall, context: &ExecContext) -> Result<u32> {
|
||||
let id = next_id(&self.next_id)?;
|
||||
self.entries.lock().await.insert(
|
||||
id,
|
||||
PendingExec {
|
||||
call: call.clone(),
|
||||
context: context.clone(),
|
||||
started_at_ms: now_ms(),
|
||||
stdout: String::new(),
|
||||
stderr: String::new(),
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub async fn call(&self, id: u32) -> Option<ToolCall> {
|
||||
self.entries
|
||||
.lock()
|
||||
.await
|
||||
.get(&id)
|
||||
.map(|entry| entry.call.clone())
|
||||
}
|
||||
|
||||
pub async fn append_stdout(&self, id: u32, data: &str) -> bool {
|
||||
let mut entries = self.entries.lock().await;
|
||||
let Some(entry) = entries.get_mut(&id) else {
|
||||
return false;
|
||||
};
|
||||
entry.stdout.push_str(data);
|
||||
true
|
||||
}
|
||||
|
||||
pub async fn append_stderr(&self, id: u32, data: &str) -> bool {
|
||||
let mut entries = self.entries.lock().await;
|
||||
let Some(entry) = entries.get_mut(&id) else {
|
||||
return false;
|
||||
};
|
||||
entry.stderr.push_str(data);
|
||||
true
|
||||
}
|
||||
|
||||
pub(crate) async fn take(&self, id: u32) -> Option<PendingExec> {
|
||||
self.entries.lock().await.remove(&id)
|
||||
}
|
||||
|
||||
pub async fn discard(&self, id: u32) {
|
||||
self.entries.lock().await.remove(&id);
|
||||
}
|
||||
|
||||
pub async fn drain_running(&self) -> Vec<u32> {
|
||||
let mut entries = self.entries.lock().await;
|
||||
let mut ids = entries.drain().map(|(id, _)| id).collect::<Vec<_>>();
|
||||
ids.sort_unstable();
|
||||
ids
|
||||
}
|
||||
}
|
||||
|
||||
impl PendingClientTools {
|
||||
pub async fn reserve(&self, call: &ToolCall, context: &ExecContext) -> Result<u32> {
|
||||
let id = next_id(&self.next_id)?;
|
||||
self.calls.lock().await.insert(
|
||||
id,
|
||||
PendingClientTool {
|
||||
call: call.clone(),
|
||||
context: context.clone(),
|
||||
started_at_ms: now_ms(),
|
||||
},
|
||||
);
|
||||
Ok(id)
|
||||
}
|
||||
|
||||
pub(crate) async fn take(&self, id: u32) -> Option<PendingClientTool> {
|
||||
self.calls.lock().await.remove(&id)
|
||||
}
|
||||
|
||||
pub async fn discard(&self, id: u32) {
|
||||
self.calls.lock().await.remove(&id);
|
||||
}
|
||||
}
|
||||
|
||||
fn next_id(counter: &AtomicU32) -> Result<u32> {
|
||||
counter
|
||||
.fetch_add(1, Ordering::Relaxed)
|
||||
.checked_add(1)
|
||||
.ok_or_else(|| Error::Protocol("Cursor message id space exhausted".into()))
|
||||
}
|
||||
|
||||
pub(crate) fn now_ms() -> u64 {
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.unwrap_or_default()
|
||||
.as_millis() as u64
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use crate::cursor::{proto::agent::v1 as pb, tools::result::ToolCompletion};
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct PresentationDelta {
|
||||
pub steps: Vec<pb::ConversationStep>,
|
||||
pub read_paths: Vec<String>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct Presentation {
|
||||
steps: Vec<pb::ConversationStep>,
|
||||
read_paths: Vec<String>,
|
||||
text: String,
|
||||
thinking: String,
|
||||
}
|
||||
|
||||
impl Presentation {
|
||||
pub fn text_delta(&mut self, delta: &str) {
|
||||
self.text.push_str(delta);
|
||||
}
|
||||
|
||||
pub fn finish_text(&mut self) {
|
||||
if self.text.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.steps.push(pb::ConversationStep {
|
||||
message: Some(pb::conversation_step::Message::AssistantMessage(
|
||||
pb::AssistantMessage {
|
||||
text: std::mem::take(&mut self.text),
|
||||
},
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn thinking_delta(&mut self, delta: &str) {
|
||||
self.thinking.push_str(delta);
|
||||
}
|
||||
|
||||
pub fn finish_thinking(&mut self, duration: Duration) {
|
||||
if self.thinking.is_empty() {
|
||||
return;
|
||||
}
|
||||
self.steps.push(pb::ConversationStep {
|
||||
message: Some(pb::conversation_step::Message::ThinkingMessage(
|
||||
pb::ThinkingMessage {
|
||||
text: std::mem::take(&mut self.thinking),
|
||||
duration_ms: duration.as_millis().min(u32::MAX as u128) as u32,
|
||||
},
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn tool_completed(&mut self, completion: &ToolCompletion) {
|
||||
if let Some(pb::tool_call::Tool::ReadToolCall(read)) = &completion.tool_call().tool {
|
||||
if matches!(
|
||||
read.result
|
||||
.as_ref()
|
||||
.and_then(|result| result.result.as_ref()),
|
||||
Some(pb::read_tool_result::Result::Success(_))
|
||||
) {
|
||||
if let Some(path) = read.args.as_ref().map(|args| &args.path) {
|
||||
if !path.is_empty() && !self.read_paths.contains(path) {
|
||||
self.read_paths.push(path.clone());
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
self.steps.push(pb::ConversationStep {
|
||||
message: Some(pb::conversation_step::Message::ToolCall(
|
||||
completion.tool_call().clone(),
|
||||
)),
|
||||
});
|
||||
}
|
||||
|
||||
pub fn take(&mut self) -> PresentationDelta {
|
||||
PresentationDelta {
|
||||
steps: std::mem::take(&mut self.steps),
|
||||
read_paths: std::mem::take(&mut self.read_paths),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn thinking_step_keeps_the_measured_duration() {
|
||||
let mut presentation = Presentation::default();
|
||||
presentation.thinking_delta("reasoning");
|
||||
presentation.finish_thinking(Duration::from_millis(6_880));
|
||||
let step = presentation.take().steps.pop().unwrap();
|
||||
let Some(pb::conversation_step::Message::ThinkingMessage(thinking)) = step.message else {
|
||||
panic!("expected thinking step");
|
||||
};
|
||||
assert_eq!(thinking.text, "reasoning");
|
||||
assert_eq!(thinking.duration_ms, 6_880);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,251 @@
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
CanonicalMessage, ContentPart, MessageContent, Origin, RecoveredToolRound, Role, ToolCall,
|
||||
ToolCallContent, ToolResultContent, ToolRoundAssistant, ToolRoundId,
|
||||
},
|
||||
store::BlobId,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::REPLAY_ENVELOPE_PREFIX;
|
||||
|
||||
pub fn decode(data: &[u8], internal_id: String) -> Result<CanonicalMessage> {
|
||||
let value: Value = serde_json::from_slice(data)?;
|
||||
let role = match required_string(&value, "role")? {
|
||||
"system" => Role::System,
|
||||
"user" => Role::User,
|
||||
"assistant" => Role::Assistant,
|
||||
"tool" => Role::Tool,
|
||||
role => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"unknown Cursor message role: {role}"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let wire_id = value
|
||||
.get("id")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.to_string();
|
||||
let origin = match role {
|
||||
Role::System => Origin::Prompt,
|
||||
Role::Assistant => Origin::Assistant,
|
||||
Role::Tool => Origin::Tool,
|
||||
Role::User if wire_id.starts_with("runtime:") => Origin::Runtime,
|
||||
Role::User
|
||||
if wire_id.starts_with("request-context:")
|
||||
|| wire_id.starts_with("selected-context:") =>
|
||||
{
|
||||
Origin::Prompt
|
||||
}
|
||||
Role::User => Origin::User,
|
||||
};
|
||||
let runtime_event_id = wire_id.strip_prefix("runtime:").map(str::to_string);
|
||||
let content = match role {
|
||||
Role::Assistant => decode_assistant(&value, &internal_id)?,
|
||||
Role::Tool => MessageContent::ToolResult(decode_tool_result(&value)?),
|
||||
_ => decode_text(&value)?,
|
||||
};
|
||||
let message_id = if runtime_event_id.is_some() {
|
||||
wire_id
|
||||
} else {
|
||||
internal_id
|
||||
};
|
||||
Ok(CanonicalMessage {
|
||||
message_id,
|
||||
role,
|
||||
origin,
|
||||
content,
|
||||
runtime_event_id,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn decode_pending(value: &str) -> Result<RecoveredToolRound> {
|
||||
let wire: Value = serde_json::from_str(value)?;
|
||||
let started_at_ms = wire
|
||||
.pointer("/providerOptions/cursor/pendingToolCallStartedAtMs")
|
||||
.and_then(Value::as_u64)
|
||||
.ok_or_else(|| {
|
||||
Error::Protocol("Cursor pending assistant is missing pendingToolCallStartedAtMs".into())
|
||||
})?;
|
||||
let internal_id = format!(
|
||||
"cursor-pending:{}",
|
||||
BlobId::digest(value.as_bytes()).to_base64()
|
||||
);
|
||||
let message = decode(value.as_bytes(), internal_id.clone())?;
|
||||
let MessageContent::Assistant {
|
||||
text,
|
||||
thinking,
|
||||
tool_round_id: _,
|
||||
replay_state,
|
||||
tool_calls,
|
||||
} = message.content
|
||||
else {
|
||||
return Err(Error::Protocol(
|
||||
"Cursor pending message is not an assistant message".into(),
|
||||
));
|
||||
};
|
||||
if tool_calls.is_empty() {
|
||||
return Err(Error::Protocol(
|
||||
"Cursor resume contains a pending assistant without tool calls".into(),
|
||||
));
|
||||
}
|
||||
let model_call_id = wire
|
||||
.pointer("/providerOptions/cursor/modelProviderMessageId")
|
||||
.and_then(Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.unwrap_or(&internal_id)
|
||||
.to_string();
|
||||
let calls = tool_calls
|
||||
.into_iter()
|
||||
.enumerate()
|
||||
.map(|(index, call)| {
|
||||
Ok(ToolCall {
|
||||
index,
|
||||
call_id: call.call_id,
|
||||
model_call_id: model_call_id.clone(),
|
||||
name: call.name,
|
||||
arguments_text: serde_json::to_string(&call.arguments)?,
|
||||
arguments: call.arguments,
|
||||
})
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(RecoveredToolRound {
|
||||
assistant: ToolRoundAssistant {
|
||||
text,
|
||||
thinking,
|
||||
model_call_id,
|
||||
replay_state,
|
||||
},
|
||||
calls,
|
||||
started_at_ms,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_text(value: &Value) -> Result<MessageContent> {
|
||||
let content = value.get("content").unwrap_or(&Value::Null);
|
||||
if let Some(text) = content.as_str() {
|
||||
return Ok(MessageContent::Parts {
|
||||
parts: vec![ContentPart::Text { text: text.into() }],
|
||||
});
|
||||
}
|
||||
let parts = content
|
||||
.as_array()
|
||||
.ok_or_else(|| Error::Protocol("Cursor message content is not an array".into()))?
|
||||
.iter()
|
||||
.map(|part| match part.get("type").and_then(Value::as_str) {
|
||||
Some("text") => Ok(ContentPart::Text {
|
||||
text: required_string(part, "text")?.into(),
|
||||
}),
|
||||
Some("image") => {
|
||||
let mime_type = required_string(part, "mimeType")?;
|
||||
let encoded = required_string(part, "data")?;
|
||||
Ok(ContentPart::Image {
|
||||
mime_type: mime_type.into(),
|
||||
data: STANDARD.decode(encoded).map_err(|error| {
|
||||
Error::Protocol(format!("invalid Cursor image base64: {error}"))
|
||||
})?,
|
||||
})
|
||||
}
|
||||
Some(kind) => Err(Error::Protocol(format!(
|
||||
"unsupported Cursor message content part: {kind}"
|
||||
))),
|
||||
None => Err(Error::Protocol(
|
||||
"Cursor message content part is missing type".into(),
|
||||
)),
|
||||
})
|
||||
.collect::<Result<Vec<_>>>()?;
|
||||
Ok(MessageContent::Parts { parts })
|
||||
}
|
||||
|
||||
fn decode_assistant(value: &Value, internal_id: &str) -> Result<MessageContent> {
|
||||
let mut text = String::new();
|
||||
let mut thinking = String::new();
|
||||
let mut calls = Vec::new();
|
||||
let mut replay_state = None;
|
||||
for part in value
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.into_iter()
|
||||
.flatten()
|
||||
{
|
||||
match part.get("type").and_then(Value::as_str) {
|
||||
Some("text") => {
|
||||
text.push_str(part.get("text").and_then(Value::as_str).unwrap_or_default())
|
||||
}
|
||||
Some("reasoning") => {
|
||||
thinking.push_str(part.get("text").and_then(Value::as_str).unwrap_or_default());
|
||||
if let Some(signature) = part.get("signature").and_then(Value::as_str) {
|
||||
if replay_state.is_some() {
|
||||
return Err(Error::Protocol(
|
||||
"Cursor assistant has multiple reasoning signatures".into(),
|
||||
));
|
||||
}
|
||||
replay_state = Some(decode_replay_state(signature)?);
|
||||
}
|
||||
}
|
||||
Some("tool-call") => calls.push(ToolCallContent {
|
||||
index: calls.len(),
|
||||
call_id: required_string(part, "toolCallId")?.into(),
|
||||
name: required_string(part, "toolName")?.into(),
|
||||
arguments: part.get("args").cloned().unwrap_or(Value::Null),
|
||||
}),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
Ok(MessageContent::Assistant {
|
||||
text,
|
||||
thinking,
|
||||
tool_round_id: (!calls.is_empty())
|
||||
.then(|| ToolRoundId::new(format!("{internal_id}:tool-round"))),
|
||||
replay_state,
|
||||
tool_calls: calls,
|
||||
})
|
||||
}
|
||||
|
||||
fn decode_replay_state(signature: &str) -> Result<crate::model::ProviderReplayState> {
|
||||
let Some(encoded) = signature.strip_prefix(REPLAY_ENVELOPE_PREFIX) else {
|
||||
return Ok(crate::model::ProviderReplayState {
|
||||
provider_kind: "cursor_opaque".into(),
|
||||
value: Value::String(signature.into()),
|
||||
});
|
||||
};
|
||||
let bytes = STANDARD.decode(encoded).map_err(|error| {
|
||||
Error::Protocol(format!(
|
||||
"invalid Cursor BYOK replay envelope base64: {error}"
|
||||
))
|
||||
})?;
|
||||
serde_json::from_slice(&bytes)
|
||||
.map_err(|error| Error::Protocol(format!("invalid Cursor BYOK replay envelope: {error}")))
|
||||
}
|
||||
|
||||
fn decode_tool_result(value: &Value) -> Result<ToolResultContent> {
|
||||
let part = value
|
||||
.get("content")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|parts| parts.first())
|
||||
.ok_or_else(|| Error::Protocol("Cursor tool message has no result part".into()))?;
|
||||
Ok(ToolResultContent {
|
||||
call_id: required_string(part, "toolCallId")?.into(),
|
||||
name: required_string(part, "toolName")?.into(),
|
||||
content: part
|
||||
.get("result")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
is_error: part
|
||||
.get("isError")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false),
|
||||
})
|
||||
}
|
||||
|
||||
fn required_string<'a>(value: &'a Value, name: &str) -> Result<&'a str> {
|
||||
value
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol(format!("Cursor message is missing {name}")))
|
||||
}
|
||||
@@ -0,0 +1,264 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use base64::{engine::general_purpose::STANDARD, Engine};
|
||||
use serde_json::{json, Map, Value};
|
||||
|
||||
use crate::{
|
||||
model::{
|
||||
project_messages, CanonicalMessage, ContentPart, ProjectedContent, ProjectedMessage, Role,
|
||||
ToolCall, ToolCallContent, ToolRoundAssistant,
|
||||
},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::REPLAY_ENVELOPE_PREFIX;
|
||||
|
||||
pub fn stable_messages(
|
||||
instructions: &str,
|
||||
messages: &[CanonicalMessage],
|
||||
model: &str,
|
||||
) -> Result<Vec<Vec<u8>>> {
|
||||
let mut projected = project_messages(messages)?;
|
||||
if !instructions.is_empty() {
|
||||
projected.insert(
|
||||
0,
|
||||
ProjectedMessage {
|
||||
message_id: "system".into(),
|
||||
role: Role::System,
|
||||
content: ProjectedContent::Parts(vec![ContentPart::Text {
|
||||
text: instructions.into(),
|
||||
}]),
|
||||
},
|
||||
);
|
||||
}
|
||||
projected
|
||||
.iter()
|
||||
.map(|message| serde_json::to_vec(&wire_message(message, model, None)?).map_err(Into::into))
|
||||
.collect::<std::result::Result<_, _>>()
|
||||
}
|
||||
|
||||
pub fn staged_tool_round(
|
||||
assistant: &ToolRoundAssistant,
|
||||
calls: &[ToolCall],
|
||||
model: &str,
|
||||
allowed_tools: &[String],
|
||||
dynamic_tools: &HashSet<String>,
|
||||
started_at_ms: u64,
|
||||
) -> Result<String> {
|
||||
let message = ProjectedMessage {
|
||||
message_id: assistant.model_call_id.clone(),
|
||||
role: Role::Assistant,
|
||||
content: ProjectedContent::Assistant {
|
||||
text: assistant.text.clone(),
|
||||
thinking: assistant.thinking.clone(),
|
||||
replay_state: assistant.replay_state.clone(),
|
||||
calls: calls
|
||||
.iter()
|
||||
.map(|call| ToolCallContent {
|
||||
index: call.index,
|
||||
call_id: call.call_id.clone(),
|
||||
name: call.name.clone(),
|
||||
arguments: call.arguments.clone(),
|
||||
})
|
||||
.collect(),
|
||||
},
|
||||
};
|
||||
Ok(serde_json::to_string(&wire_message(
|
||||
&message,
|
||||
model,
|
||||
Some(PendingContext {
|
||||
allowed_tools,
|
||||
dynamic_tools,
|
||||
started_at_ms,
|
||||
}),
|
||||
)?)?)
|
||||
}
|
||||
|
||||
pub fn staged_final(
|
||||
message: &CanonicalMessage,
|
||||
model: &str,
|
||||
allowed_tools: &[String],
|
||||
dynamic_tools: &HashSet<String>,
|
||||
started_at_ms: u64,
|
||||
) -> Result<String> {
|
||||
let projected = project_messages(std::slice::from_ref(message))?;
|
||||
let assistant = projected
|
||||
.first()
|
||||
.filter(|message| message.role == Role::Assistant)
|
||||
.ok_or_else(|| {
|
||||
Error::Protocol("final checkpoint stage is not an assistant message".into())
|
||||
})?;
|
||||
Ok(serde_json::to_string(&wire_message(
|
||||
assistant,
|
||||
model,
|
||||
Some(PendingContext {
|
||||
allowed_tools,
|
||||
dynamic_tools,
|
||||
started_at_ms,
|
||||
}),
|
||||
)?)?)
|
||||
}
|
||||
|
||||
#[derive(Clone, Copy)]
|
||||
pub(super) struct PendingContext<'a> {
|
||||
allowed_tools: &'a [String],
|
||||
dynamic_tools: &'a HashSet<String>,
|
||||
started_at_ms: u64,
|
||||
}
|
||||
|
||||
pub(super) fn wire_message(
|
||||
message: &ProjectedMessage,
|
||||
model: &str,
|
||||
pending: Option<PendingContext<'_>>,
|
||||
) -> Result<Value> {
|
||||
let mut root = Map::new();
|
||||
root.insert(
|
||||
"role".into(),
|
||||
Value::String(role_name(&message.role).into()),
|
||||
);
|
||||
root.insert("content".into(), wire_content(&message.content, model)?);
|
||||
root.insert("id".into(), Value::String(wire_message_id(message)));
|
||||
if let ProjectedContent::Assistant { calls, .. } = &message.content {
|
||||
let mut cursor = Map::new();
|
||||
if let Some(pending) = pending {
|
||||
cursor.insert(
|
||||
"pendingToolCallStartedAtMs".into(),
|
||||
json!(pending.started_at_ms),
|
||||
);
|
||||
cursor.insert(
|
||||
"pendingToolExecutionContracts".into(),
|
||||
Value::Object(
|
||||
calls
|
||||
.iter()
|
||||
.map(|call| {
|
||||
(
|
||||
call.call_id.clone(),
|
||||
json!({
|
||||
"toolCallId": call.call_id,
|
||||
"outerToolName": call.name,
|
||||
"toolIdentifier": tool_identifier(&call.name, pending.dynamic_tools),
|
||||
"isDynamic": pending.dynamic_tools.contains(&call.name),
|
||||
"allowedToolNames": pending.allowed_tools,
|
||||
}),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
);
|
||||
}
|
||||
if !cursor.is_empty() {
|
||||
root.insert("providerOptions".into(), json!({"cursor": cursor}));
|
||||
}
|
||||
}
|
||||
Ok(Value::Object(root))
|
||||
}
|
||||
|
||||
fn tool_identifier(name: &str, dynamic_tools: &HashSet<String>) -> String {
|
||||
if dynamic_tools.contains(name) {
|
||||
return name.into();
|
||||
}
|
||||
match name {
|
||||
"AwaitShell" => "AWAIT".into(),
|
||||
"CallMcpTool" => "MCP".into(),
|
||||
"CreatePlan" => "CREATE_PLAN_V2".into(),
|
||||
"UpdateCurrentStep" => "COMMUNICATE_UPDATE".into(),
|
||||
_ => name
|
||||
.chars()
|
||||
.enumerate()
|
||||
.fold(String::new(), |mut value, (index, character)| {
|
||||
if index > 0 && character.is_ascii_uppercase() {
|
||||
value.push('_');
|
||||
}
|
||||
value.push(character.to_ascii_uppercase());
|
||||
value
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_message_id(message: &ProjectedMessage) -> String {
|
||||
match &message.content {
|
||||
ProjectedContent::Assistant { .. } => "1".into(),
|
||||
ProjectedContent::ToolResult(result) => result.call_id.clone(),
|
||||
ProjectedContent::Parts(_) => message.message_id.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
fn wire_content(content: &ProjectedContent, model: &str) -> Result<Value> {
|
||||
Ok(match content {
|
||||
ProjectedContent::Parts(parts) => Value::Array(
|
||||
parts
|
||||
.iter()
|
||||
.map(|part| match part {
|
||||
ContentPart::Text { text } => json!({"type":"text", "text":text}),
|
||||
ContentPart::Image { mime_type, data } => json!({
|
||||
"type":"image",
|
||||
"data": STANDARD.encode(data),
|
||||
"mimeType": mime_type,
|
||||
}),
|
||||
})
|
||||
.collect(),
|
||||
),
|
||||
ProjectedContent::Assistant {
|
||||
text,
|
||||
thinking,
|
||||
replay_state,
|
||||
calls,
|
||||
} => {
|
||||
let mut parts = Vec::new();
|
||||
if !thinking.is_empty() || replay_state.is_some() {
|
||||
let mut reasoning = json!({
|
||||
"type": "reasoning",
|
||||
"text": thinking,
|
||||
"providerOptions": {"cursor": {"modelName": model}},
|
||||
});
|
||||
if let Some(replay_state) = replay_state {
|
||||
reasoning["signature"] = Value::String(encode_replay_state(replay_state)?);
|
||||
}
|
||||
parts.push(reasoning);
|
||||
}
|
||||
if !text.is_empty() {
|
||||
parts.push(json!({"type":"text", "text":text}));
|
||||
}
|
||||
parts.extend(calls.iter().map(|call| {
|
||||
json!({
|
||||
"type": "tool-call",
|
||||
"toolCallId": call.call_id,
|
||||
"toolName": call.name,
|
||||
"args": call.arguments,
|
||||
})
|
||||
}));
|
||||
Value::Array(parts)
|
||||
}
|
||||
ProjectedContent::ToolResult(result) => json!([{
|
||||
"type": "tool-result",
|
||||
"toolCallId": result.call_id,
|
||||
"toolName": result.name,
|
||||
"result": result.content,
|
||||
"experimental_content": [{"type":"text", "text":result.content}],
|
||||
"isError": result.is_error,
|
||||
}]),
|
||||
})
|
||||
}
|
||||
|
||||
fn encode_replay_state(replay_state: &crate::model::ProviderReplayState) -> Result<String> {
|
||||
if replay_state.provider_kind == "cursor_opaque" {
|
||||
return replay_state
|
||||
.value
|
||||
.as_str()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| Error::Protocol("Cursor opaque replay state is not a string".into()));
|
||||
}
|
||||
Ok(format!(
|
||||
"{REPLAY_ENVELOPE_PREFIX}{}",
|
||||
STANDARD.encode(serde_json::to_vec(replay_state)?)
|
||||
))
|
||||
}
|
||||
|
||||
fn role_name(role: &Role) -> &'static str {
|
||||
match role {
|
||||
Role::System => "system",
|
||||
Role::User => "user",
|
||||
Role::Assistant => "assistant",
|
||||
Role::Tool => "tool",
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
mod decode;
|
||||
mod encode;
|
||||
|
||||
pub use decode::{decode, decode_pending};
|
||||
pub use encode::{stable_messages, staged_final, staged_tool_round};
|
||||
|
||||
const REPLAY_ENVELOPE_PREFIX: &str = "cursor-byok:v1:";
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
@@ -0,0 +1,211 @@
|
||||
use std::collections::HashSet;
|
||||
|
||||
use serde_json::{json, Value};
|
||||
|
||||
use crate::model::{
|
||||
project_messages, CanonicalMessage, MessageContent, ProjectedContent, ProjectedMessage,
|
||||
ProviderReplayState, Role, ToolCall, ToolResultContent, ToolRoundAssistant,
|
||||
};
|
||||
|
||||
use super::{decode, decode_pending, encode::wire_message, staged_tool_round};
|
||||
|
||||
#[test]
|
||||
fn pending_tool_round_is_one_complete_assistant_message_and_round_trips() {
|
||||
let replay_state = ProviderReplayState {
|
||||
provider_kind: "anthropic".into(),
|
||||
value: json!({"blocks":[{"type":"thinking","thinking":"why","signature":"sig"}]}),
|
||||
};
|
||||
let assistant = ToolRoundAssistant {
|
||||
text: "before tools".into(),
|
||||
thinking: "why".into(),
|
||||
model_call_id: "model-call".into(),
|
||||
replay_state: Some(replay_state.clone()),
|
||||
};
|
||||
let calls = vec![
|
||||
ToolCall {
|
||||
index: 0,
|
||||
call_id: "a".into(),
|
||||
model_call_id: "model-call".into(),
|
||||
name: "Read".into(),
|
||||
arguments_text: r#"{"path":"/a"}"#.into(),
|
||||
arguments: json!({"path":"/a"}),
|
||||
},
|
||||
ToolCall {
|
||||
index: 1,
|
||||
call_id: "b".into(),
|
||||
model_call_id: "model-call".into(),
|
||||
name: "Grep".into(),
|
||||
arguments_text: r#"{"pattern":"x"}"#.into(),
|
||||
arguments: json!({"pattern":"x"}),
|
||||
},
|
||||
];
|
||||
let pending = staged_tool_round(
|
||||
&assistant,
|
||||
&calls,
|
||||
"claude",
|
||||
&["Read".into(), "Grep".into()],
|
||||
&HashSet::new(),
|
||||
42,
|
||||
)
|
||||
.unwrap();
|
||||
let wire: Value = serde_json::from_str(&pending).unwrap();
|
||||
assert_eq!(wire["id"], "1");
|
||||
assert_eq!(
|
||||
wire["providerOptions"]["cursor"]["pendingToolExecutionContracts"]["a"]["toolIdentifier"],
|
||||
"READ"
|
||||
);
|
||||
assert_eq!(wire["role"], "assistant");
|
||||
assert_eq!(
|
||||
wire["providerOptions"]["cursor"]["pendingToolExecutionContracts"]
|
||||
.as_object()
|
||||
.unwrap()
|
||||
.len(),
|
||||
2
|
||||
);
|
||||
assert_eq!(
|
||||
wire["content"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|part| part["type"] == "tool-call")
|
||||
.count(),
|
||||
2
|
||||
);
|
||||
|
||||
let recovered = decode_pending(&pending).unwrap();
|
||||
assert_eq!(recovered.assistant.replay_state, Some(replay_state));
|
||||
assert_eq!(recovered.calls.len(), 2);
|
||||
assert_eq!(recovered.calls[0].call_id, "a");
|
||||
assert_eq!(recovered.calls[1].call_id, "b");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_wire_ids_are_projection_metadata_not_internal_message_ids() {
|
||||
let assistant = ProjectedMessage {
|
||||
message_id: "internal-assistant-id".into(),
|
||||
role: Role::Assistant,
|
||||
content: ProjectedContent::Assistant {
|
||||
text: "done".into(),
|
||||
thinking: String::new(),
|
||||
replay_state: None,
|
||||
calls: Vec::new(),
|
||||
},
|
||||
};
|
||||
let result = ProjectedMessage {
|
||||
message_id: "internal-result-id".into(),
|
||||
role: Role::Tool,
|
||||
content: ProjectedContent::ToolResult(ToolResultContent {
|
||||
call_id: "call-1".into(),
|
||||
name: "Read".into(),
|
||||
content: "ok".into(),
|
||||
is_error: false,
|
||||
}),
|
||||
};
|
||||
|
||||
assert_eq!(wire_message(&assistant, "model", None).unwrap()["id"], "1");
|
||||
assert_eq!(
|
||||
wire_message(&result, "model", None).unwrap()["id"],
|
||||
"call-1"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn runtime_wire_identity_survives_checkpoint_hydration() {
|
||||
let wire = json!({
|
||||
"role": "user",
|
||||
"id": "runtime:subagent-completed:child-id",
|
||||
"content": "child completed",
|
||||
});
|
||||
let message = decode(
|
||||
serde_json::to_vec(&wire).unwrap().as_slice(),
|
||||
"cursor-root:blob-id:19".into(),
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(message.message_id, "runtime:subagent-completed:child-id");
|
||||
assert_eq!(
|
||||
message.runtime_event_id.as_deref(),
|
||||
Some("subagent-completed:child-id")
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn repeated_cursor_wire_ids_do_not_merge_distinct_tool_rounds() {
|
||||
fn assistant(call_id: &str, internal_id: &str) -> CanonicalMessage {
|
||||
let wire = json!({
|
||||
"role": "assistant",
|
||||
"id": "1",
|
||||
"content": [{
|
||||
"type": "tool-call",
|
||||
"toolCallId": call_id,
|
||||
"toolName": "Read",
|
||||
"args": {"path": format!("/{call_id}")},
|
||||
}],
|
||||
});
|
||||
decode(
|
||||
serde_json::to_vec(&wire).unwrap().as_slice(),
|
||||
internal_id.into(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
fn result(call_id: &str, internal_id: &str) -> CanonicalMessage {
|
||||
let wire = json!({
|
||||
"role": "tool",
|
||||
"id": call_id,
|
||||
"content": [{
|
||||
"type": "tool-result",
|
||||
"toolCallId": call_id,
|
||||
"toolName": "Read",
|
||||
"result": "ok",
|
||||
}],
|
||||
});
|
||||
decode(
|
||||
serde_json::to_vec(&wire).unwrap().as_slice(),
|
||||
internal_id.into(),
|
||||
)
|
||||
.unwrap()
|
||||
}
|
||||
|
||||
let messages = vec![
|
||||
assistant("a", "cursor-root:a"),
|
||||
result("a", "cursor-root:a-result"),
|
||||
assistant("b", "cursor-root:b"),
|
||||
result("b", "cursor-root:b-result"),
|
||||
];
|
||||
assert_ne!(messages[0].message_id, messages[2].message_id);
|
||||
let projected = project_messages(&messages).unwrap();
|
||||
assert_eq!(projected.len(), 4);
|
||||
assert!(matches!(
|
||||
&projected[0].content,
|
||||
ProjectedContent::Assistant { calls, .. } if calls[0].call_id == "a"
|
||||
));
|
||||
assert!(matches!(
|
||||
&projected[2].content,
|
||||
ProjectedContent::Assistant { calls, .. } if calls[0].call_id == "b"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn opaque_cursor_reasoning_signature_round_trips_without_decoding() {
|
||||
let signature = "opaque-url-safe_signature-value";
|
||||
let wire = json!({
|
||||
"role": "assistant",
|
||||
"id": "1",
|
||||
"content": [{"type":"reasoning", "text":"", "signature":signature}],
|
||||
});
|
||||
let message = decode(
|
||||
serde_json::to_vec(&wire).unwrap().as_slice(),
|
||||
"cursor-root:opaque".into(),
|
||||
)
|
||||
.unwrap();
|
||||
let MessageContent::Assistant { replay_state, .. } = &message.content else {
|
||||
panic!("expected assistant");
|
||||
};
|
||||
assert_eq!(
|
||||
replay_state.as_ref().unwrap().provider_kind,
|
||||
"cursor_opaque"
|
||||
);
|
||||
let projected = project_messages(&[message]).unwrap();
|
||||
let encoded = wire_message(&projected[0], "model", None).unwrap();
|
||||
assert_eq!(encoded["content"][0]["signature"], signature);
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
use std::{path::Path, sync::OnceLock};
|
||||
|
||||
use crate::{model::ToolDefinition, Error, Result};
|
||||
|
||||
use super::catalog::Catalog;
|
||||
|
||||
static EMBEDDED_PROMPTS: include_dir::Dir<'_> =
|
||||
include_dir::include_dir!("$CARGO_MANIFEST_DIR/../prompt/cursor");
|
||||
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
|
||||
pub enum Mode {
|
||||
Agent,
|
||||
Ask,
|
||||
Plan,
|
||||
Debug,
|
||||
Multitask,
|
||||
Subagent,
|
||||
Compaction,
|
||||
}
|
||||
|
||||
impl Mode {
|
||||
pub fn parse(value: &str) -> Result<Self> {
|
||||
match value.to_ascii_lowercase().as_str() {
|
||||
"agent" => Ok(Self::Agent),
|
||||
"ask" => Ok(Self::Ask),
|
||||
"plan" => Ok(Self::Plan),
|
||||
"debug" => Ok(Self::Debug),
|
||||
"multitask" => Ok(Self::Multitask),
|
||||
"subagent" => Ok(Self::Subagent),
|
||||
"compaction" => Ok(Self::Compaction),
|
||||
other => Err(Error::Config(format!("unknown prompt mode: {other}"))),
|
||||
}
|
||||
}
|
||||
|
||||
fn name(self) -> &'static str {
|
||||
match self {
|
||||
Self::Agent => "agent",
|
||||
Self::Ask => "ask",
|
||||
Self::Plan => "plan",
|
||||
Self::Debug => "debug",
|
||||
Self::Multitask => "multitask",
|
||||
Self::Subagent => "subagent",
|
||||
Self::Compaction => "compaction",
|
||||
}
|
||||
}
|
||||
|
||||
fn index(self) -> usize {
|
||||
match self {
|
||||
Self::Agent => 0,
|
||||
Self::Ask => 1,
|
||||
Self::Plan => 2,
|
||||
Self::Debug => 3,
|
||||
Self::Multitask => 4,
|
||||
Self::Subagent => 5,
|
||||
Self::Compaction => 6,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct ModeAssets {
|
||||
pub prompt: String,
|
||||
pub runtime: String,
|
||||
pub tools: Vec<ToolDefinition>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub struct PromptAssets {
|
||||
modes: [ModeAssets; 7],
|
||||
}
|
||||
|
||||
impl PromptAssets {
|
||||
pub fn load(root: &Path) -> Result<Self> {
|
||||
Self::read(|path| {
|
||||
let path = root.join(path);
|
||||
path.exists()
|
||||
.then(|| std::fs::read_to_string(path).map_err(Error::from))
|
||||
.transpose()
|
||||
})
|
||||
}
|
||||
|
||||
pub fn embedded() -> Result<Self> {
|
||||
Self::read(|path| {
|
||||
EMBEDDED_PROMPTS
|
||||
.get_file(path)
|
||||
.map(|file| {
|
||||
file.contents_utf8()
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| Error::Config(format!("prompt asset is not UTF-8: {path}")))
|
||||
})
|
||||
.transpose()
|
||||
})
|
||||
}
|
||||
|
||||
fn read(mut asset: impl FnMut(&str) -> Result<Option<String>>) -> Result<Self> {
|
||||
let catalog = Catalog::parse(
|
||||
&asset("tools.json")?
|
||||
.ok_or_else(|| Error::Config("missing Cursor tools.json".into()))?,
|
||||
)?;
|
||||
let mut modes = Vec::with_capacity(7);
|
||||
for mode in [
|
||||
Mode::Agent,
|
||||
Mode::Ask,
|
||||
Mode::Plan,
|
||||
Mode::Debug,
|
||||
Mode::Multitask,
|
||||
Mode::Subagent,
|
||||
Mode::Compaction,
|
||||
] {
|
||||
let prompt = asset(&format!("{}/prompt.md", mode.name()))?
|
||||
.ok_or_else(|| Error::Config(format!("missing prompt for {mode:?}")))?;
|
||||
let runtime = asset(&format!("{}/runtime.md", mode.name()))?
|
||||
.ok_or_else(|| Error::Config(format!("missing runtime template for {mode:?}")))?;
|
||||
validate_runtime_template(mode, &runtime)?;
|
||||
let manifest = asset(&format!("modes/{}.json", mode.name()))?
|
||||
.ok_or_else(|| Error::Config(format!("missing manifest for {mode:?}")))?;
|
||||
let tools = catalog.select_json(&manifest)?;
|
||||
modes.push(ModeAssets {
|
||||
prompt,
|
||||
runtime,
|
||||
tools,
|
||||
});
|
||||
}
|
||||
Ok(Self {
|
||||
modes: modes
|
||||
.try_into()
|
||||
.map_err(|_| Error::Config("incomplete Cursor prompt mode catalog".into()))?,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn mode(&self, mode: Mode) -> &ModeAssets {
|
||||
&self.modes[mode.index()]
|
||||
}
|
||||
}
|
||||
|
||||
const RUNTIME_VARIABLES: &[&str] = &[
|
||||
"REQUEST_CONTEXT",
|
||||
"OPEN_FILES",
|
||||
"SELECTED_CONTEXT",
|
||||
"ACTION_CONTEXT",
|
||||
"TIMESTAMP",
|
||||
"USER_QUERY",
|
||||
"DEBUG_SERVER_ENDPOINT",
|
||||
"DEBUG_LOG_PATH",
|
||||
"DEBUG_SESSION_ID",
|
||||
];
|
||||
|
||||
fn validate_runtime_template(mode: Mode, template: &str) -> Result<()> {
|
||||
let expression = runtime_expression();
|
||||
for capture in expression.captures_iter(template) {
|
||||
let name = &capture[1];
|
||||
if !RUNTIME_VARIABLES.contains(&name) {
|
||||
return Err(Error::Config(format!(
|
||||
"unknown variable in {mode:?} runtime template: {name}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
for required in ["TIMESTAMP", "USER_QUERY"] {
|
||||
let token = format!("{{{{{required}}}}}");
|
||||
if !template.contains(&token) {
|
||||
return Err(Error::Config(format!(
|
||||
"{mode:?} runtime template is missing {token}"
|
||||
)));
|
||||
}
|
||||
}
|
||||
let stripped = expression.replace_all(template, "");
|
||||
if stripped.contains("{{") || stripped.contains("}}") {
|
||||
return Err(Error::Config(format!(
|
||||
"malformed placeholder in {mode:?} runtime template"
|
||||
)));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub(super) fn runtime_expression() -> &'static regex::Regex {
|
||||
static EXPRESSION: OnceLock<regex::Regex> = OnceLock::new();
|
||||
EXPRESSION.get_or_init(|| {
|
||||
regex::Regex::new(r"\{\{([A-Z_]+)\}\}").expect("valid runtime placeholder expression")
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
use std::collections::HashMap;
|
||||
|
||||
use serde::Deserialize;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{model::ToolDefinition, Error, Result};
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct Manifest {
|
||||
tools: Vec<ManifestTool>,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
#[serde(untagged)]
|
||||
enum ManifestTool {
|
||||
Name(String),
|
||||
Variant { name: String, variant: String },
|
||||
}
|
||||
|
||||
pub(super) struct Catalog {
|
||||
tools: HashMap<String, ToolDefinition>,
|
||||
variants: HashMap<String, ToolDefinition>,
|
||||
}
|
||||
|
||||
impl Catalog {
|
||||
pub(super) fn parse(json: &str) -> Result<Self> {
|
||||
let value: Value = serde_json::from_str(json)?;
|
||||
let tools = value
|
||||
.get("tools")
|
||||
.and_then(Value::as_array)
|
||||
.ok_or_else(|| Error::Config("tools.json is missing tools".into()))?
|
||||
.iter()
|
||||
.map(parse_tool)
|
||||
.map(|result| result.map(|tool| (tool.name.clone(), tool)))
|
||||
.collect::<Result<HashMap<_, _>>>()?;
|
||||
let variants = value
|
||||
.get("variants")
|
||||
.and_then(Value::as_object)
|
||||
.into_iter()
|
||||
.flat_map(|variants| variants.iter())
|
||||
.map(|(name, value)| parse_tool(value).map(|tool| (name.clone(), tool)))
|
||||
.collect::<Result<HashMap<_, _>>>()?;
|
||||
Ok(Self { tools, variants })
|
||||
}
|
||||
|
||||
pub(super) fn select_json(&self, manifest: &str) -> Result<Vec<ToolDefinition>> {
|
||||
let manifest: Manifest = serde_json::from_str(manifest)?;
|
||||
self.select(&manifest)
|
||||
}
|
||||
|
||||
fn select(&self, manifest: &Manifest) -> Result<Vec<ToolDefinition>> {
|
||||
manifest
|
||||
.tools
|
||||
.iter()
|
||||
.map(|entry| match entry {
|
||||
ManifestTool::Name(name) => self.tools.get(name).cloned().ok_or_else(|| {
|
||||
Error::Config(format!("tool manifest references unknown schema: {name}"))
|
||||
}),
|
||||
ManifestTool::Variant { name, variant } => self
|
||||
.variants
|
||||
.get(&format!("{name}.{variant}"))
|
||||
.cloned()
|
||||
.ok_or_else(|| {
|
||||
Error::Config(format!(
|
||||
"tool manifest references unknown variant: {name}.{variant}"
|
||||
))
|
||||
}),
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_tool(tool: &Value) -> Result<ToolDefinition> {
|
||||
let function = tool
|
||||
.get("function")
|
||||
.ok_or_else(|| Error::Config("tool is missing function".into()))?;
|
||||
Ok(ToolDefinition {
|
||||
name: function
|
||||
.get("name")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| Error::Config("tool is missing name".into()))?
|
||||
.into(),
|
||||
description: function
|
||||
.get("description")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| Error::Config("tool is missing description".into()))?
|
||||
.into(),
|
||||
parameters: function
|
||||
.get("parameters")
|
||||
.cloned()
|
||||
.ok_or_else(|| Error::Config("tool is missing parameters".into()))?,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{
|
||||
model::{PromptSpec, ToolDefinition},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::{assets::runtime_expression, Mode, PromptAssets};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct PromptCompiler {
|
||||
assets: PromptAssets,
|
||||
}
|
||||
|
||||
impl PromptCompiler {
|
||||
pub fn new(assets: PromptAssets) -> Self {
|
||||
Self { assets }
|
||||
}
|
||||
|
||||
pub fn runtime_message(&self, mode: Mode, values: &BTreeMap<&str, String>) -> Result<String> {
|
||||
render(&self.assets.mode(mode).runtime, values)
|
||||
}
|
||||
|
||||
pub fn prompt_spec(
|
||||
&self,
|
||||
mode: Mode,
|
||||
model: &str,
|
||||
dynamic_tools: &[ToolDefinition],
|
||||
suppress_subagent_progress: bool,
|
||||
) -> Result<PromptSpec> {
|
||||
let mut tools = self.tools(mode, suppress_subagent_progress);
|
||||
let mut dynamic_tools = dynamic_tools.to_vec();
|
||||
dynamic_tools.sort_by(|left, right| left.name.cmp(&right.name));
|
||||
append_dynamic_tools(&mut tools, dynamic_tools)?;
|
||||
Ok(PromptSpec {
|
||||
instructions: self
|
||||
.assets
|
||||
.mode(mode)
|
||||
.prompt
|
||||
.replace("{{FAKE_MODEL_NAME}}", model),
|
||||
tools,
|
||||
})
|
||||
}
|
||||
|
||||
fn tools(&self, mode: Mode, suppress_subagent_progress: bool) -> Vec<ToolDefinition> {
|
||||
let mut tools = self.assets.mode(mode).tools.clone();
|
||||
if mode == Mode::Subagent && suppress_subagent_progress {
|
||||
tools.retain(|tool| tool.name != "UpdateCurrentStep");
|
||||
}
|
||||
tools
|
||||
}
|
||||
}
|
||||
|
||||
fn render(template: &str, values: &BTreeMap<&str, String>) -> Result<String> {
|
||||
let expression = runtime_expression();
|
||||
let mut output = String::with_capacity(template.len());
|
||||
let mut cursor = 0;
|
||||
for capture in expression.captures_iter(template) {
|
||||
let token = capture.get(0).expect("runtime template token");
|
||||
let name = &capture[1];
|
||||
let value = values
|
||||
.get(name)
|
||||
.ok_or_else(|| Error::Protocol(format!("runtime template value is missing: {name}")))?;
|
||||
output.push_str(&template[cursor..token.start()]);
|
||||
output.push_str(value);
|
||||
cursor = token.end();
|
||||
}
|
||||
output.push_str(&template[cursor..]);
|
||||
Ok(output.trim().to_string())
|
||||
}
|
||||
|
||||
fn append_dynamic_tools(
|
||||
tools: &mut Vec<ToolDefinition>,
|
||||
additions: Vec<ToolDefinition>,
|
||||
) -> Result<()> {
|
||||
for tool in additions {
|
||||
if tools.iter().any(|existing| existing.name == tool.name) {
|
||||
return Err(Error::Protocol(format!(
|
||||
"dynamic MCP tool conflicts with a mode tool: {}",
|
||||
tool.name
|
||||
)));
|
||||
}
|
||||
tools.push(tool);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
+3
-4
@@ -23,10 +23,9 @@ pub fn fold_derived_state(messages: &[CanonicalMessage]) -> DerivedState {
|
||||
}
|
||||
}
|
||||
MessageContent::ToolResult(result) if !result.is_error => {
|
||||
let (name, input) = calls
|
||||
.get(&result.call_id)
|
||||
.cloned()
|
||||
.unwrap_or_else(|| (result.name.clone(), result.output.clone()));
|
||||
let Some((name, input)) = calls.get(&result.call_id).cloned() else {
|
||||
continue;
|
||||
};
|
||||
match normalize(&name).as_str() {
|
||||
"todowrite" | "updatetodos" => state.todos = Some(input),
|
||||
"createplan" | "updateplan" | "writeplan" => state.plan = Some(input),
|
||||
@@ -1,9 +1,8 @@
|
||||
mod assets;
|
||||
mod catalog;
|
||||
mod compiler;
|
||||
mod derived_state;
|
||||
mod projector;
|
||||
|
||||
pub use assets::*;
|
||||
pub use compiler::*;
|
||||
pub use derived_state::*;
|
||||
pub use projector::*;
|
||||
@@ -0,0 +1,242 @@
|
||||
use std::time::Instant;
|
||||
|
||||
use axum::{
|
||||
body::{to_bytes, Body, Bytes},
|
||||
extract::Extension,
|
||||
http::{header, Request, Response},
|
||||
};
|
||||
|
||||
use crate::Result;
|
||||
|
||||
const CURSOR_UPSTREAM: &str = "https://api2.cursor.sh";
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CursorProxy {
|
||||
client: reqwest::Client,
|
||||
upstream: String,
|
||||
}
|
||||
|
||||
pub struct BufferedResponse {
|
||||
pub status: axum::http::StatusCode,
|
||||
pub headers: axum::http::HeaderMap,
|
||||
pub body: Bytes,
|
||||
}
|
||||
|
||||
impl BufferedResponse {
|
||||
pub fn into_response(self) -> Response<Body> {
|
||||
let body = self.body.clone();
|
||||
self.with_body(body)
|
||||
}
|
||||
|
||||
pub fn with_body(self, body: Bytes) -> Response<Body> {
|
||||
let mut response = Response::new(Body::from(body));
|
||||
*response.status_mut() = self.status;
|
||||
*response.headers_mut() = self.headers;
|
||||
response
|
||||
}
|
||||
}
|
||||
|
||||
impl CursorProxy {
|
||||
pub fn cursor() -> Result<Self> {
|
||||
Self::for_upstream(CURSOR_UPSTREAM)
|
||||
}
|
||||
|
||||
fn for_upstream(upstream: &str) -> Result<Self> {
|
||||
Ok(Self {
|
||||
client: reqwest::Client::builder()
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()?,
|
||||
upstream: upstream.trim_end_matches('/').to_owned(),
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn forward(
|
||||
Extension(proxy): Extension<CursorProxy>,
|
||||
request: Request<Body>,
|
||||
) -> Result<Response<Body>> {
|
||||
let started = Instant::now();
|
||||
let (parts, body) = request.into_parts();
|
||||
let path = parts
|
||||
.uri
|
||||
.path_and_query()
|
||||
.map_or("/", |value| value.as_str());
|
||||
let url = format!("{}{path}", proxy.upstream);
|
||||
|
||||
let mut headers = parts.headers;
|
||||
headers.remove(header::HOST);
|
||||
remove_hop_by_hop_headers(&mut headers);
|
||||
|
||||
let upstream = proxy
|
||||
.client
|
||||
.request(parts.method.clone(), url)
|
||||
.headers(headers)
|
||||
.body(reqwest::Body::wrap_stream(body.into_data_stream()))
|
||||
.send()
|
||||
.await;
|
||||
|
||||
let upstream = match upstream {
|
||||
Ok(response) => response,
|
||||
Err(error) => {
|
||||
tracing::error!(
|
||||
method = %parts.method,
|
||||
path,
|
||||
elapsed_ms = started.elapsed().as_millis(),
|
||||
%error,
|
||||
"Cursor upstream request failed"
|
||||
);
|
||||
return Err(error.into());
|
||||
}
|
||||
};
|
||||
|
||||
let status = upstream.status();
|
||||
let mut response_headers = upstream.headers().clone();
|
||||
remove_hop_by_hop_headers(&mut response_headers);
|
||||
let mut response = Response::new(Body::from_stream(upstream.bytes_stream()));
|
||||
*response.status_mut() = status;
|
||||
*response.headers_mut() = response_headers;
|
||||
|
||||
tracing::info!(
|
||||
method = %parts.method,
|
||||
path,
|
||||
%status,
|
||||
elapsed_ms = started.elapsed().as_millis(),
|
||||
"forwarded Cursor backend request"
|
||||
);
|
||||
Ok(response)
|
||||
}
|
||||
|
||||
pub async fn forward_buffered(
|
||||
proxy: &CursorProxy,
|
||||
request: Request<Body>,
|
||||
) -> Result<BufferedResponse> {
|
||||
let (parts, body) = request.into_parts();
|
||||
let path = parts
|
||||
.uri
|
||||
.path_and_query()
|
||||
.map_or("/", |value| value.as_str());
|
||||
let mut headers = parts.headers;
|
||||
headers.remove(header::HOST);
|
||||
remove_hop_by_hop_headers(&mut headers);
|
||||
headers.insert(
|
||||
"connect-accept-encoding",
|
||||
axum::http::HeaderValue::from_static("identity"),
|
||||
);
|
||||
headers.insert(
|
||||
header::ACCEPT_ENCODING,
|
||||
axum::http::HeaderValue::from_static("identity"),
|
||||
);
|
||||
let body = to_bytes(body, usize::MAX)
|
||||
.await
|
||||
.map_err(|error| crate::Error::Protocol(format!("cannot read request body: {error}")))?;
|
||||
let upstream = proxy
|
||||
.client
|
||||
.request(parts.method, format!("{}{path}", proxy.upstream))
|
||||
.headers(headers)
|
||||
.body(body)
|
||||
.send()
|
||||
.await?;
|
||||
let status = upstream.status();
|
||||
let mut headers = upstream.headers().clone();
|
||||
remove_hop_by_hop_headers(&mut headers);
|
||||
let body = upstream.bytes().await?;
|
||||
Ok(BufferedResponse {
|
||||
status,
|
||||
headers,
|
||||
body,
|
||||
})
|
||||
}
|
||||
|
||||
fn remove_hop_by_hop_headers(headers: &mut axum::http::HeaderMap) {
|
||||
let connection_headers = headers
|
||||
.get(header::CONNECTION)
|
||||
.and_then(|value| value.to_str().ok())
|
||||
.map(|value| {
|
||||
value
|
||||
.split(',')
|
||||
.map(str::trim)
|
||||
.filter(|name| !name.is_empty())
|
||||
.map(str::to_owned)
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
for name in connection_headers {
|
||||
headers.remove(name);
|
||||
}
|
||||
for name in [
|
||||
header::CONNECTION,
|
||||
header::PROXY_AUTHENTICATE,
|
||||
header::PROXY_AUTHORIZATION,
|
||||
header::TE,
|
||||
header::TRAILER,
|
||||
header::TRANSFER_ENCODING,
|
||||
header::UPGRADE,
|
||||
] {
|
||||
headers.remove(name);
|
||||
}
|
||||
headers.remove("keep-alive");
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use axum::{
|
||||
body::{to_bytes, Body},
|
||||
extract::Extension,
|
||||
http::{header, Request, StatusCode},
|
||||
response::IntoResponse,
|
||||
routing::any,
|
||||
Router,
|
||||
};
|
||||
use tower::ServiceExt;
|
||||
|
||||
use super::{forward, CursorProxy};
|
||||
|
||||
#[tokio::test]
|
||||
async fn preserves_request_and_response() {
|
||||
let upstream = Router::new().route(
|
||||
"/unknown",
|
||||
any(|request: Request<Body>| async move {
|
||||
let method = request.method().clone();
|
||||
let query = request.uri().query().unwrap_or_default().to_owned();
|
||||
let marker = request.headers()["x-marker"].clone();
|
||||
let body = to_bytes(request.into_body(), usize::MAX).await.unwrap();
|
||||
(
|
||||
StatusCode::CREATED,
|
||||
[(header::CONTENT_TYPE, "application/proto")],
|
||||
format!(
|
||||
"{method} {query} {} {}",
|
||||
marker.to_str().unwrap(),
|
||||
String::from_utf8_lossy(&body)
|
||||
),
|
||||
)
|
||||
.into_response()
|
||||
}),
|
||||
);
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let address = listener.local_addr().unwrap();
|
||||
let server = tokio::spawn(async move { axum::serve(listener, upstream).await.unwrap() });
|
||||
let proxy = CursorProxy::for_upstream(&format!("http://{address}")).unwrap();
|
||||
let app = Router::new().fallback(forward).layer(Extension(proxy));
|
||||
|
||||
let response = app
|
||||
.oneshot(
|
||||
Request::put("/unknown?a=1")
|
||||
.header("x-marker", "kept")
|
||||
.body(Body::from("payload"))
|
||||
.unwrap(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
|
||||
assert_eq!(response.status(), StatusCode::CREATED);
|
||||
assert_eq!(
|
||||
response.headers()[header::CONTENT_TYPE],
|
||||
"application/proto"
|
||||
);
|
||||
assert_eq!(
|
||||
to_bytes(response.into_body(), usize::MAX).await.unwrap(),
|
||||
"PUT a=1 kept payload"
|
||||
);
|
||||
server.abort();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
use std::collections::BTreeSet;
|
||||
|
||||
use crate::{cursor::proto::agent::v1 as pb, Error, Result};
|
||||
|
||||
pub(super) const FOLLOW_UP: &str = concat!(
|
||||
"Perform any necessary follow-up actions in response to the subagent completion above. ",
|
||||
"If no follow-up work is needed, no further action is required. ",
|
||||
"If you mention an agent or subagent in your response, link it with the `[Name](id)` ",
|
||||
"Don't use generic label such as `[agent]`, `[worker]`, or `[subagent]`. ",
|
||||
"For cloud subagents, when the agent has edited code, link to `[Review](bc-id#changes)`, ",
|
||||
"or, if you know the exact added and deleted line counts, `[Review +A −D](bc-id#changes)`, ",
|
||||
"replacing A and D with those counts. Never write A or D literally. ",
|
||||
"Use `[Try Live](bc-id#desktop)` only when the agent used computer use. ",
|
||||
"Don't repeat the same confirmation every time."
|
||||
);
|
||||
|
||||
#[derive(Debug)]
|
||||
pub(super) struct Projection {
|
||||
pub context: String,
|
||||
pub turn_user: pb::UserMessage,
|
||||
}
|
||||
|
||||
pub(super) fn project(
|
||||
action: &pb::BackgroundTaskCompletionAction,
|
||||
mode: i32,
|
||||
) -> Result<Projection> {
|
||||
if action.completions.is_empty() {
|
||||
return Err(Error::Protocol(
|
||||
"background task completion action contains no completion".into(),
|
||||
));
|
||||
}
|
||||
|
||||
let mut ids = BTreeSet::new();
|
||||
let mut contexts = Vec::with_capacity(action.completions.len());
|
||||
for completion in &action.completions {
|
||||
let kind = pb::BackgroundTaskKind::try_from(completion.kind).map_err(|_| {
|
||||
Error::Protocol(format!("unknown background task kind: {}", completion.kind))
|
||||
})?;
|
||||
if kind != pb::BackgroundTaskKind::Subagent {
|
||||
return Err(Error::Protocol(format!(
|
||||
"unsupported background task completion kind: {}",
|
||||
kind.as_str_name()
|
||||
)));
|
||||
}
|
||||
let reason =
|
||||
pb::BackgroundTaskCompletionReason::try_from(completion.reason).map_err(|_| {
|
||||
Error::Protocol(format!(
|
||||
"unknown background task completion reason: {}",
|
||||
completion.reason
|
||||
))
|
||||
})?;
|
||||
if reason != pb::BackgroundTaskCompletionReason::TaskFinished {
|
||||
return Err(Error::Protocol(format!(
|
||||
"subagent notification is not a finished task: {}",
|
||||
reason.as_str_name()
|
||||
)));
|
||||
}
|
||||
let id = completion
|
||||
.subagent_id
|
||||
.as_deref()
|
||||
.filter(|id| !id.is_empty())
|
||||
.ok_or_else(|| {
|
||||
Error::Protocol("background subagent completion has no subagent_id".into())
|
||||
})?;
|
||||
if completion.task_id.is_empty() || completion.title.is_empty() {
|
||||
return Err(Error::Protocol(
|
||||
"background subagent completion requires task_id and title".into(),
|
||||
));
|
||||
}
|
||||
if !ids.insert(id) {
|
||||
return Err(Error::Protocol(format!(
|
||||
"duplicate background subagent completion: {id}"
|
||||
)));
|
||||
}
|
||||
contexts.push(completion_context(completion, id)?);
|
||||
}
|
||||
|
||||
let first = &action.completions[0];
|
||||
let message_id = ids.iter().copied().collect::<Vec<_>>().join(":");
|
||||
Ok(Projection {
|
||||
context: contexts.join("\n\n"),
|
||||
turn_user: pb::UserMessage {
|
||||
text: FOLLOW_UP.into(),
|
||||
message_id: format!("subagent-completed:{message_id}"),
|
||||
mode,
|
||||
is_simulated_msg: Some(true),
|
||||
simulated_msg_reason: Some(pb::SimulatedMsgReason::BackgroundTaskCompletion as i32),
|
||||
simulated_message_metadata: Some(pb::user_message::SimulatedMessageMetadata {
|
||||
title: Some(first.title.clone()),
|
||||
task_id: Some(first.task_id.clone()),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
fn completion_context(completion: &pb::BackgroundTaskCompletion, id: &str) -> Result<String> {
|
||||
let status = pb::BackgroundTaskStatus::try_from(completion.status).map_err(|_| {
|
||||
Error::Protocol(format!(
|
||||
"unknown background task status: {}",
|
||||
completion.status
|
||||
))
|
||||
})?;
|
||||
if status == pb::BackgroundTaskStatus::Unspecified {
|
||||
return Err(Error::Protocol(
|
||||
"background subagent completion has unspecified status".into(),
|
||||
));
|
||||
}
|
||||
let mut fields = vec![
|
||||
format!("Title: {}", completion.title),
|
||||
format!("Subagent ID: {id}"),
|
||||
format!("Status: {}", status.as_str_name()),
|
||||
];
|
||||
if let Some(tool_call_id) = completion
|
||||
.tool_call_id
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
fields.push(format!("Tool call ID: {tool_call_id}"));
|
||||
}
|
||||
if let Some(output_path) = completion
|
||||
.output_path
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
fields.push(format!("Output path: {output_path}"));
|
||||
}
|
||||
if let Some(detail) = completion
|
||||
.detail
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
{
|
||||
fields.push(detail.into());
|
||||
}
|
||||
Ok(format!(
|
||||
"<background_task_completion>\n{}\n</background_task_completion>",
|
||||
fields.join("\n")
|
||||
))
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn finished_subagent_becomes_an_idempotent_user_runtime_event() {
|
||||
let action = pb::BackgroundTaskCompletionAction {
|
||||
completions: vec![completion()],
|
||||
};
|
||||
let projection = project(&action, pb::AgentMode::Multitask as i32).unwrap();
|
||||
|
||||
assert!(projection.context.contains("Subagent ID: child-id"));
|
||||
assert!(projection.context.contains("child result"));
|
||||
|
||||
assert_eq!(projection.turn_user.text, FOLLOW_UP);
|
||||
assert_eq!(projection.turn_user.is_simulated_msg, Some(true));
|
||||
assert_eq!(
|
||||
projection.turn_user.simulated_msg_reason,
|
||||
Some(pb::SimulatedMsgReason::BackgroundTaskCompletion as i32)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn completion_requires_the_captured_subagent_identity_and_terminal_reason() {
|
||||
let mut value = completion();
|
||||
value.subagent_id = None;
|
||||
assert!(project(
|
||||
&pb::BackgroundTaskCompletionAction {
|
||||
completions: vec![value]
|
||||
},
|
||||
pb::AgentMode::Agent as i32
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("subagent_id"));
|
||||
|
||||
let mut value = completion();
|
||||
value.reason = pb::BackgroundTaskCompletionReason::TaskProgress as i32;
|
||||
assert!(project(
|
||||
&pb::BackgroundTaskCompletionAction {
|
||||
completions: vec![value]
|
||||
},
|
||||
pb::AgentMode::Agent as i32
|
||||
)
|
||||
.unwrap_err()
|
||||
.to_string()
|
||||
.contains("not a finished task"));
|
||||
}
|
||||
|
||||
fn completion() -> pb::BackgroundTaskCompletion {
|
||||
pb::BackgroundTaskCompletion {
|
||||
task_id: "child-id".into(),
|
||||
kind: pb::BackgroundTaskKind::Subagent as i32,
|
||||
status: pb::BackgroundTaskStatus::Success as i32,
|
||||
title: "Inspect protocol".into(),
|
||||
detail: Some("child result".into()),
|
||||
reason: pb::BackgroundTaskCompletionReason::TaskFinished as i32,
|
||||
subagent_id: Some("child-id".into()),
|
||||
tool_call_id: Some("task-call".into()),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,369 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use prost::Message;
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
cursor::{blob_sync::BlobSynchronizer, proto::agent::v1 as pb},
|
||||
model::ToolDefinition,
|
||||
store::BlobId,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
pub async fn hydrate(
|
||||
request: &pb::AgentRunRequest,
|
||||
blobs: &BlobSynchronizer,
|
||||
) -> Result<pb::RequestContext> {
|
||||
let mut context = request_context(request).cloned().unwrap_or_default();
|
||||
let Some(parts) = request
|
||||
.action
|
||||
.as_ref()
|
||||
.and_then(|action| action.request_context_parts.as_ref())
|
||||
else {
|
||||
return Ok(context);
|
||||
};
|
||||
|
||||
if let Some(part) = decode_part::<pb::RequestContextRulesPart>(
|
||||
"rules",
|
||||
&parts.rules_blob_id,
|
||||
parts.rules_byte_length,
|
||||
blobs,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
context.rules = part.rules;
|
||||
context.non_file_rules = part.non_file_rules;
|
||||
context.cloud_rule = part.cloud_rule;
|
||||
}
|
||||
if let Some(part) = decode_part::<pb::RequestContextSkillsPart>(
|
||||
"skills",
|
||||
&parts.skills_blob_id,
|
||||
parts.skills_byte_length,
|
||||
blobs,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
context.agent_skills = part.agent_skills;
|
||||
context.skill_options = part.skill_options;
|
||||
}
|
||||
if let Some(part) = decode_part::<pb::RequestContextSubagentsPart>(
|
||||
"subagents",
|
||||
&parts.subagents_blob_id,
|
||||
parts.subagents_byte_length,
|
||||
blobs,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
context.custom_subagents = part.custom_subagents;
|
||||
}
|
||||
if let Some(part) = decode_part::<pb::RequestContextMcpsPart>(
|
||||
"MCP",
|
||||
&parts.mcps_blob_id,
|
||||
parts.mcps_byte_length,
|
||||
blobs,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
context.tools = part.tools;
|
||||
context.mcp_instructions = part.mcp_instructions;
|
||||
context.mcp_file_system_options = part.mcp_file_system_options;
|
||||
context.mcp_meta_tool_options = part.mcp_meta_tool_options;
|
||||
}
|
||||
Ok(context)
|
||||
}
|
||||
|
||||
async fn decode_part<T: Message + Default>(
|
||||
name: &str,
|
||||
raw_id: &[u8],
|
||||
expected_length: u32,
|
||||
blobs: &BlobSynchronizer,
|
||||
) -> Result<Option<T>> {
|
||||
if raw_id.is_empty() {
|
||||
if expected_length != 0 {
|
||||
return Err(Error::Protocol(format!(
|
||||
"{name} context has a byte length but no BlobID"
|
||||
)));
|
||||
}
|
||||
return Ok(None);
|
||||
}
|
||||
let id = BlobId::from_bytes(raw_id)?;
|
||||
let data = blobs.get(&id).await?.ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"{name} context Blob is missing: {}",
|
||||
id.to_base64()
|
||||
))
|
||||
})?;
|
||||
if data.len() != expected_length as usize {
|
||||
return Err(Error::Protocol(format!(
|
||||
"{name} context Blob length mismatch: expected {expected_length}, got {}",
|
||||
data.len()
|
||||
)));
|
||||
}
|
||||
T::decode(data.as_slice())
|
||||
.map(Some)
|
||||
.map_err(|error| Error::Protocol(format!("invalid {name} context Blob: {error}")))
|
||||
}
|
||||
|
||||
pub fn request_context(request: &pb::AgentRunRequest) -> Option<&pb::RequestContext> {
|
||||
let action = request.action.as_ref()?;
|
||||
action
|
||||
.request_context_parts
|
||||
.as_ref()
|
||||
.and_then(|parts| parts.dynamic_context.as_ref())
|
||||
.or_else(|| match action.action.as_ref()? {
|
||||
pb::conversation_action::Action::UserMessageAction(action) => {
|
||||
action.request_context.as_ref()
|
||||
}
|
||||
_ => None,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn compile_context(context: &pb::RequestContext, today: &str) -> String {
|
||||
let mut sections = Vec::new();
|
||||
let mut transcripts = None;
|
||||
if let Some(env) = &context.env {
|
||||
let workspace = env
|
||||
.workspace_paths
|
||||
.first()
|
||||
.map(String::as_str)
|
||||
.unwrap_or("");
|
||||
let repo = context.git_repos.iter().find(|repo| repo.path == workspace);
|
||||
sections.push(format!(
|
||||
"<user_info>\nOS Version: {}\n\nShell: {}\n\nWorkspace Path: {}\n\nIs directory a git repo: {}\n\nTerminals folder: {}\n\nToday's date: {}\n\nNote: Prefer using absolute paths over relative paths as tool call args when possible.\n</user_info>",
|
||||
env.os_version,
|
||||
env.shell,
|
||||
workspace,
|
||||
repo.map(|repo| format!("Yes, at {}", repo.path)).unwrap_or_else(|| "No".into()),
|
||||
env.terminals_folder,
|
||||
today,
|
||||
));
|
||||
if !env.agent_transcripts_folder.is_empty() {
|
||||
transcripts = Some(format!(
|
||||
"<agent_transcripts>\nAgent transcripts (past chats) live in {}. They have names like <uuid>.jsonl, cite parent chat transcripts to the user as [<title for chat <=6 words>\n](<uuid excluding .jsonl>). Don't discuss the folder structure.\n</agent_transcripts>",
|
||||
env.agent_transcripts_folder
|
||||
));
|
||||
}
|
||||
}
|
||||
sections.extend(context.git_repos.iter().map(|repo| {
|
||||
format!(
|
||||
"<git_status>\nThis is the git status at the start of the conversation. Note that this status is a snapshot in time, and will not update during the conversation.\n\n\nGit repo: {}\n\n```\n{}\n```\n</git_status>",
|
||||
repo.path, repo.status
|
||||
)
|
||||
}));
|
||||
sections.extend(transcripts);
|
||||
let mut rules = context
|
||||
.rules
|
||||
.iter()
|
||||
.chain(context.non_file_rules.iter())
|
||||
.map(|rule| format!("<user_rule>{}</user_rule>", rule.content))
|
||||
.collect::<Vec<_>>();
|
||||
rules.extend(
|
||||
context
|
||||
.cloud_rule
|
||||
.iter()
|
||||
.map(|rule| format!("<user_rule>{rule}</user_rule>")),
|
||||
);
|
||||
if !rules.is_empty() {
|
||||
sections.push(format!("<rules>\n{}\n</rules>", rules.join("\n")));
|
||||
}
|
||||
let skills = context
|
||||
.agent_skills
|
||||
.iter()
|
||||
.filter(|skill| !skill.disable_model_invocation)
|
||||
.map(|skill| {
|
||||
format!(
|
||||
"<agent_skill fullPath=\"{}\">{}</agent_skill>",
|
||||
xml(&skill.full_path),
|
||||
skill.description
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !skills.is_empty() {
|
||||
sections.push(format!(
|
||||
"<agent_skills>\n<available_skills>\n{}\n</available_skills>\n</agent_skills>",
|
||||
skills.join("\n")
|
||||
));
|
||||
}
|
||||
let subagents = context
|
||||
.custom_subagents
|
||||
.iter()
|
||||
.map(|agent| {
|
||||
format!(
|
||||
"<subagent name=\"{}\">{}</subagent>",
|
||||
xml(&agent.name),
|
||||
agent.description
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !subagents.is_empty() {
|
||||
sections.push(format!(
|
||||
"<subagents>\n{}\n</subagents>",
|
||||
subagents.join("\n")
|
||||
));
|
||||
}
|
||||
if let Some(options) = &context.mcp_meta_tool_options {
|
||||
let servers = options
|
||||
.mcp_descriptors
|
||||
.iter()
|
||||
.map(|server| {
|
||||
let tools = server
|
||||
.tools
|
||||
.iter()
|
||||
.map(|tool| tool.tool_name.as_str())
|
||||
.collect::<Vec<_>>()
|
||||
.join(", ");
|
||||
format!(
|
||||
"<mcp_meta_tool_server name=\"{}\" tools=\"{}\"{} />",
|
||||
xml(&server.server_identifier),
|
||||
xml(&tools),
|
||||
server
|
||||
.server_use_instructions
|
||||
.as_deref()
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!(" serverUseInstructions=\"{}\"", xml(value)))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
if !servers.is_empty() {
|
||||
sections.push(format!(
|
||||
"<mcp_meta_tools>\n<mcp_meta_tool_servers>\n{}\n</mcp_meta_tool_servers>\n</mcp_meta_tools>",
|
||||
servers.join("\n")
|
||||
));
|
||||
}
|
||||
}
|
||||
sections.join("\n\n")
|
||||
}
|
||||
|
||||
pub fn selected_context(user: &pb::UserMessage) -> Option<String> {
|
||||
let selected = user.selected_context.as_ref()?;
|
||||
let mut sections = selected.extra_context.clone();
|
||||
sections.extend(
|
||||
selected
|
||||
.files
|
||||
.iter()
|
||||
.map(|file| format!("<file path=\"{}\">\n{}\n</file>", file.path, file.content)),
|
||||
);
|
||||
sections.extend(
|
||||
selected
|
||||
.code_selections
|
||||
.iter()
|
||||
.map(|value| format!("<code path=\"{}\">\n{}\n</code>", value.path, value.content)),
|
||||
);
|
||||
sections.extend(selected.terminals.iter().map(|value| {
|
||||
format!(
|
||||
"<terminal title=\"{}\">\n{}\n</terminal>",
|
||||
value.title.as_deref().unwrap_or_default(),
|
||||
value.content
|
||||
)
|
||||
}));
|
||||
sections.extend(selected.terminal_selections.iter().map(|value| {
|
||||
format!(
|
||||
"<terminal_selection title=\"{}\">\n{}\n</terminal_selection>",
|
||||
value.title.as_deref().unwrap_or_default(),
|
||||
value.content
|
||||
)
|
||||
}));
|
||||
sections.extend(selected.cursor_rules.iter().filter_map(|value| {
|
||||
value.rule.as_ref().map(|rule| {
|
||||
format!(
|
||||
"<rule path=\"{}\">\n{}\n</rule>",
|
||||
rule.full_path, rule.content
|
||||
)
|
||||
})
|
||||
}));
|
||||
sections.extend(selected.cursor_commands.iter().map(|value| {
|
||||
format!(
|
||||
"<command name=\"{}\">\n{}\n</command>",
|
||||
value.name, value.content
|
||||
)
|
||||
}));
|
||||
sections.extend(selected.selected_skills.iter().map(|value| {
|
||||
format!(
|
||||
"<skill path=\"{}\">\n{}\n{}\n</skill>",
|
||||
value.full_path, value.description, value.content
|
||||
)
|
||||
}));
|
||||
sections.extend(selected.external_links.iter().map(|value| {
|
||||
format!(
|
||||
"External link: {}{}",
|
||||
value.url,
|
||||
value
|
||||
.pdf_content
|
||||
.as_deref()
|
||||
.map(|content| format!("\n{content}"))
|
||||
.unwrap_or_default()
|
||||
)
|
||||
}));
|
||||
Some(sections.join("\n\n"))
|
||||
}
|
||||
|
||||
pub fn dynamic_mcp(
|
||||
request: &pb::AgentRunRequest,
|
||||
context: &pb::RequestContext,
|
||||
) -> Result<BTreeMap<String, (pb::McpToolDefinition, ToolDefinition)>> {
|
||||
let direct = request
|
||||
.mcp_tools
|
||||
.iter()
|
||||
.flat_map(|tools| tools.mcp_tools.iter());
|
||||
let contextual = context.tools.iter();
|
||||
let mut output = BTreeMap::new();
|
||||
for wire in direct.chain(contextual) {
|
||||
if wire.name.is_empty() {
|
||||
return Err(Error::Protocol(
|
||||
"MCP tool definition is missing name".into(),
|
||||
));
|
||||
}
|
||||
let parameters = match wire.input_schema_json.as_deref() {
|
||||
Some(json) if !json.trim().is_empty() => serde_json::from_str(json)?,
|
||||
_ => prost_value(wire.input_schema.as_ref().ok_or_else(|| {
|
||||
Error::Protocol(format!("MCP tool {} is missing input schema", wire.name))
|
||||
})?),
|
||||
};
|
||||
let definition = ToolDefinition {
|
||||
name: wire.name.clone(),
|
||||
description: wire.description.clone(),
|
||||
parameters,
|
||||
};
|
||||
if output
|
||||
.insert(wire.name.clone(), (wire.clone(), definition))
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::Protocol(format!(
|
||||
"duplicate MCP tool definition: {}",
|
||||
wire.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
Ok(output)
|
||||
}
|
||||
|
||||
fn prost_value(value: &prost_types::Value) -> Value {
|
||||
use prost_types::value::Kind;
|
||||
match value.kind.as_ref() {
|
||||
None | Some(Kind::NullValue(_)) => Value::Null,
|
||||
Some(Kind::NumberValue(value)) => serde_json::Number::from_f64(*value)
|
||||
.map(Value::Number)
|
||||
.unwrap_or(Value::Null),
|
||||
Some(Kind::StringValue(value)) => Value::String(value.clone()),
|
||||
Some(Kind::BoolValue(value)) => Value::Bool(*value),
|
||||
Some(Kind::StructValue(value)) => Value::Object(
|
||||
value
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), prost_value(value)))
|
||||
.collect(),
|
||||
),
|
||||
Some(Kind::ListValue(value)) => {
|
||||
Value::Array(value.values.iter().map(prost_value).collect())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn xml(value: &str) -> String {
|
||||
value
|
||||
.replace('&', "&")
|
||||
.replace('"', """)
|
||||
.replace('<', "<")
|
||||
.replace('>', ">")
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
use crate::{
|
||||
cursor::{blob_sync::BlobSynchronizer, proto::agent::v1 as pb},
|
||||
model::ContentPart,
|
||||
store::BlobId,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
pub async fn parts(
|
||||
message: &pb::UserMessage,
|
||||
text: String,
|
||||
blobs: &BlobSynchronizer,
|
||||
) -> Result<Vec<ContentPart>> {
|
||||
let mut parts = vec![ContentPart::Text { text }];
|
||||
if let Some(context) = &message.selected_context {
|
||||
for image in &context.selected_images {
|
||||
parts.push(ContentPart::Image {
|
||||
mime_type: image_mime_type(image)?,
|
||||
data: image_data(image, blobs).await?,
|
||||
});
|
||||
}
|
||||
}
|
||||
Ok(parts)
|
||||
}
|
||||
|
||||
fn image_mime_type(image: &pb::SelectedImage) -> Result<String> {
|
||||
let mime_type = image.mime_type.trim();
|
||||
if !mime_type.starts_with("image/") || mime_type.len() == "image/".len() {
|
||||
return Err(Error::Protocol(format!(
|
||||
"selected image has invalid MIME type: {}",
|
||||
image.mime_type
|
||||
)));
|
||||
}
|
||||
Ok(mime_type.into())
|
||||
}
|
||||
|
||||
async fn image_data(image: &pb::SelectedImage, blobs: &BlobSynchronizer) -> Result<Vec<u8>> {
|
||||
use pb::selected_image::DataOrBlobId;
|
||||
|
||||
let data = match image.data_or_blob_id.as_ref() {
|
||||
Some(DataOrBlobId::Data(data)) => data.clone(),
|
||||
Some(DataOrBlobId::BlobId(raw_id)) => {
|
||||
let id = BlobId::from_bytes(raw_id)?;
|
||||
blobs.get(&id).await?.ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"selected image Blob is missing: {}",
|
||||
id.to_base64()
|
||||
))
|
||||
})?
|
||||
}
|
||||
Some(DataOrBlobId::BlobIdWithData(value)) => {
|
||||
let id = BlobId::from_bytes(&value.blob_id)?;
|
||||
if value.data.is_empty() {
|
||||
blobs.get(&id).await?.ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"selected image Blob is missing: {}",
|
||||
id.to_base64()
|
||||
))
|
||||
})?
|
||||
} else {
|
||||
blobs.cache_received(&id, &value.data).await?;
|
||||
value.data.clone()
|
||||
}
|
||||
}
|
||||
None => {
|
||||
return Err(Error::Protocol(
|
||||
"selected image is missing data_or_blob_id".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
if data.is_empty() {
|
||||
return Err(Error::Protocol("selected image data is empty".into()));
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mod background;
|
||||
mod context;
|
||||
mod images;
|
||||
mod model;
|
||||
mod prepare;
|
||||
mod runtime;
|
||||
|
||||
pub use prepare::*;
|
||||
@@ -0,0 +1,249 @@
|
||||
use crate::{
|
||||
cursor::proto::agent::v1 as pb,
|
||||
model::{ModelLatency, ModelSpec, ReasoningSpec, SubagentKind, SubagentModelOverride},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
pub fn requested_model(request: &pb::AgentRunRequest) -> Result<ModelSpec> {
|
||||
let details = request.model_details.as_ref();
|
||||
let model = if let Some(requested) = request.requested_model.as_ref() {
|
||||
from_requested(requested, details)?
|
||||
} else if let Some(model_id) = details
|
||||
.map(|model| model.model_id.as_str())
|
||||
.filter(|model| !model.is_empty())
|
||||
{
|
||||
ModelSpec {
|
||||
model_id: model_id.into(),
|
||||
display_name: details
|
||||
.map(|model| model.display_name.clone())
|
||||
.filter(|name| !name.is_empty()),
|
||||
reasoning: ReasoningSpec {
|
||||
enabled: details.is_some_and(|model| model.thinking_details.is_some()),
|
||||
effort: None,
|
||||
},
|
||||
latency: ModelLatency::Standard,
|
||||
max_output_tokens: None,
|
||||
context_window_tokens: None,
|
||||
extra_params: serde_json::json!({}),
|
||||
}
|
||||
} else {
|
||||
return Err(Error::Protocol("Cursor Run does not select a model".into()));
|
||||
};
|
||||
Ok(model)
|
||||
}
|
||||
|
||||
pub fn selected_models(request: &pb::AgentRunRequest) -> Result<Vec<ModelSpec>> {
|
||||
request
|
||||
.selected_subagent_models
|
||||
.iter()
|
||||
.enumerate()
|
||||
.map(|(index, model)| {
|
||||
from_requested(model, request.selected_subagent_model_details.get(index))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn overrides(
|
||||
request: &pb::AgentRunRequest,
|
||||
) -> Result<Vec<(SubagentKind, SubagentModelOverride)>> {
|
||||
request
|
||||
.subagent_model_overrides
|
||||
.iter()
|
||||
.map(|value| {
|
||||
use pb::subagent_model_override::Selection;
|
||||
let kind = subagent_kind(&value.subagent_type);
|
||||
let selection = match value.selection.as_ref() {
|
||||
Some(Selection::Model(model)) => {
|
||||
SubagentModelOverride::Explicit(from_requested(model, None)?)
|
||||
}
|
||||
Some(Selection::Inherit(true)) => SubagentModelOverride::Inherit,
|
||||
Some(Selection::Disabled(true)) => SubagentModelOverride::Disabled,
|
||||
None | Some(Selection::Inherit(false) | Selection::Disabled(false)) => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"Cursor subagent model override {} has no active selection",
|
||||
value.subagent_type
|
||||
)))
|
||||
}
|
||||
};
|
||||
Ok((kind, selection))
|
||||
})
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn subagent_kind(value: &str) -> SubagentKind {
|
||||
if value == "generalPurpose" {
|
||||
SubagentKind::GeneralPurpose
|
||||
} else {
|
||||
SubagentKind::Named(value.into())
|
||||
}
|
||||
}
|
||||
|
||||
fn from_requested(
|
||||
model: &pb::RequestedModel,
|
||||
details: Option<&pb::ModelDetails>,
|
||||
) -> Result<ModelSpec> {
|
||||
let mut spec = ModelSpec {
|
||||
model_id: model.model_id.clone(),
|
||||
display_name: details
|
||||
.map(|model| model.display_name.clone())
|
||||
.filter(|name| !name.is_empty()),
|
||||
reasoning: ReasoningSpec {
|
||||
enabled: model.max_mode
|
||||
|| details.is_some_and(|model| model.thinking_details.is_some()),
|
||||
effort: None,
|
||||
},
|
||||
latency: ModelLatency::Standard,
|
||||
max_output_tokens: None,
|
||||
context_window_tokens: None,
|
||||
extra_params: serde_json::json!({}),
|
||||
};
|
||||
for parameter in &model.parameters {
|
||||
match parameter.id.as_str() {
|
||||
"effort" | "reasoning" => {
|
||||
let effort = parameter.value.trim();
|
||||
spec.reasoning.effort =
|
||||
(effort != "none" && !effort.is_empty()).then(|| effort.to_string());
|
||||
spec.reasoning.enabled |= spec.reasoning.effort.is_some();
|
||||
}
|
||||
"thinking" => spec.reasoning.enabled |= parse_bool(parameter)?,
|
||||
"fast" => {
|
||||
if parse_bool(parameter)? {
|
||||
spec.latency = ModelLatency::Fast;
|
||||
}
|
||||
}
|
||||
"context" => {
|
||||
spec.context_window_tokens =
|
||||
Some(parse_token_count(¶meter.value).ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"invalid Cursor context token count: {}",
|
||||
parameter.value
|
||||
))
|
||||
})?);
|
||||
}
|
||||
other => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"unsupported Cursor model parameter: {other}"
|
||||
)))
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(spec)
|
||||
}
|
||||
|
||||
fn parse_bool(parameter: &pb::requested_model::ModelParameterValue) -> Result<bool> {
|
||||
match parameter.value.as_str() {
|
||||
"true" => Ok(true),
|
||||
"false" => Ok(false),
|
||||
_ => Err(Error::Protocol(format!(
|
||||
"invalid Cursor boolean model parameter {}={}",
|
||||
parameter.id, parameter.value
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn parse_token_count(value: &str) -> Option<u64> {
|
||||
let value = value.trim().to_ascii_lowercase();
|
||||
let (number, multiplier) = match value.chars().last()? {
|
||||
'k' => (&value[..value.len() - 1], 1_000),
|
||||
'm' => (&value[..value.len() - 1], 1_000_000),
|
||||
_ => (value.as_str(), 1),
|
||||
};
|
||||
number.parse::<u64>().ok()?.checked_mul(multiplier)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn requested(id: &str, parameters: &[(&str, &str)]) -> pb::RequestedModel {
|
||||
pb::RequestedModel {
|
||||
model_id: id.into(),
|
||||
parameters: parameters
|
||||
.iter()
|
||||
.map(|(id, value)| pb::requested_model::ModelParameterValue {
|
||||
id: (*id).into(),
|
||||
value: (*value).into(),
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_model_parameters_keep_order_and_define_reasoning() {
|
||||
let model = from_requested(
|
||||
&requested("grok-4.6", &[("effort", "xhigh"), ("fast", "false")]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(model.model_id, "grok-4.6");
|
||||
assert!(model.reasoning.enabled);
|
||||
assert_eq!(model.reasoning.effort.as_deref(), Some("xhigh"));
|
||||
assert_eq!(model.latency, ModelLatency::Standard);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_reasoning_and_context_metadata_are_normalized() {
|
||||
let model = from_requested(
|
||||
&requested(
|
||||
"gpt-5.6-sol",
|
||||
&[("context", "272k"), ("reasoning", "medium")],
|
||||
),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(model.context_window_tokens, Some(272_000));
|
||||
assert_eq!(model.reasoning.effort.as_deref(), Some("medium"));
|
||||
assert!(model.reasoning.enabled);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn subagent_override_distinguishes_explicit_inherit_and_disabled() {
|
||||
let request = pb::AgentRunRequest {
|
||||
subagent_model_overrides: vec![
|
||||
pb::SubagentModelOverride {
|
||||
subagent_type: "explore".into(),
|
||||
selection: Some(pb::subagent_model_override::Selection::Model(requested(
|
||||
"claude-opus-5",
|
||||
&[("thinking", "true")],
|
||||
))),
|
||||
},
|
||||
pb::SubagentModelOverride {
|
||||
subagent_type: "generalPurpose".into(),
|
||||
selection: Some(pb::subagent_model_override::Selection::Inherit(true)),
|
||||
},
|
||||
pb::SubagentModelOverride {
|
||||
subagent_type: "shell".into(),
|
||||
selection: Some(pb::subagent_model_override::Selection::Disabled(true)),
|
||||
},
|
||||
],
|
||||
..Default::default()
|
||||
};
|
||||
let overrides = overrides(&request).unwrap();
|
||||
assert!(matches!(
|
||||
&overrides[0],
|
||||
(SubagentKind::Named(name), SubagentModelOverride::Explicit(model))
|
||||
if name == "explore" && model.reasoning.enabled
|
||||
));
|
||||
assert!(matches!(
|
||||
&overrides[1],
|
||||
(SubagentKind::GeneralPurpose, SubagentModelOverride::Inherit)
|
||||
));
|
||||
assert!(matches!(
|
||||
&overrides[2],
|
||||
(SubagentKind::Named(name), SubagentModelOverride::Disabled) if name == "shell"
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn cursor_only_parameters_do_not_leak_into_model_spec() {
|
||||
let model = from_requested(
|
||||
&requested("grok-4.6", &[("fast", "true"), ("context", "300k")]),
|
||||
None,
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(model.latency, ModelLatency::Fast);
|
||||
assert_eq!(model.context_window_tokens, Some(300_000));
|
||||
assert!(from_requested(&requested("grok-4.6", &[("mystery", "x")]), None).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,411 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{
|
||||
cursor::prompting::{Mode, PromptCompiler},
|
||||
cursor::{
|
||||
blob_sync::BlobSynchronizer,
|
||||
checkpoint::CheckpointBuilder,
|
||||
projection,
|
||||
proto::agent::v1 as pb,
|
||||
tools::runtime::{ExecContext, SubagentModel},
|
||||
},
|
||||
model::{
|
||||
CanonicalMessage, ContentPart, ConversationId, MessageContent, Origin, PreparedRun, Role,
|
||||
RunAction, RunId, RunKind,
|
||||
},
|
||||
store::Store,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::{background, context, model, runtime};
|
||||
|
||||
struct ActionProjection {
|
||||
mode: i32,
|
||||
turn_user: Option<pb::UserMessage>,
|
||||
action_context: String,
|
||||
event_id: Option<String>,
|
||||
input_id: Option<String>,
|
||||
starts_turn: bool,
|
||||
}
|
||||
|
||||
pub struct CursorRunContext {
|
||||
pub request_id: String,
|
||||
pub mode: i32,
|
||||
pub turn_user: Option<pb::UserMessage>,
|
||||
pub exec: ExecContext,
|
||||
pub dynamic_tools: BTreeMap<String, pb::McpToolDefinition>,
|
||||
}
|
||||
|
||||
pub(crate) struct PrepareDependencies<'a> {
|
||||
pub compiler: &'a PromptCompiler,
|
||||
pub store: &'a Store,
|
||||
pub checkpoint: &'a CheckpointBuilder,
|
||||
pub blob_sync: &'a BlobSynchronizer,
|
||||
}
|
||||
|
||||
pub(crate) async fn prepare(
|
||||
request_id: &str,
|
||||
request: &pb::AgentRunRequest,
|
||||
parent: Option<(RunId, String)>,
|
||||
dependencies: PrepareDependencies<'_>,
|
||||
) -> Result<(PreparedRun, CursorRunContext)> {
|
||||
let PrepareDependencies {
|
||||
compiler,
|
||||
store,
|
||||
checkpoint,
|
||||
blob_sync,
|
||||
} = dependencies;
|
||||
checkpoint
|
||||
.import_prefetched(&request.pre_fetched_blobs)
|
||||
.await?;
|
||||
let conversation_id = ConversationId::new(
|
||||
request
|
||||
.conversation_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| request_id.into()),
|
||||
);
|
||||
// RunSSE/Bidi request_id identifies this concrete execution attempt. Cursor may
|
||||
// reuse AgentRunRequest.run_id when a queued or subagent-driven attempt resumes.
|
||||
let run_id = RunId::new(request_id);
|
||||
let mut base_messages = if request.conversation_state.is_some() {
|
||||
Some(
|
||||
checkpoint
|
||||
.hydrate_messages(request.conversation_state.as_ref())
|
||||
.await?,
|
||||
)
|
||||
} else {
|
||||
None
|
||||
};
|
||||
let request_context = context::hydrate(request, blob_sync).await?;
|
||||
let ActionProjection {
|
||||
mode: mode_number,
|
||||
turn_user,
|
||||
action_context,
|
||||
event_id,
|
||||
input_id,
|
||||
starts_turn,
|
||||
} = action(request_id, request)?;
|
||||
let mode = if request.subagent_type_name.is_some() {
|
||||
Mode::Subagent
|
||||
} else {
|
||||
mode_from_proto(mode_number)?
|
||||
};
|
||||
let model = model::requested_model(request)?;
|
||||
let dynamic = context::dynamic_mcp(request, &request_context)?;
|
||||
let prompt = compiler.prompt_spec(
|
||||
mode,
|
||||
&model.model_id,
|
||||
&dynamic
|
||||
.values()
|
||||
.map(|(_, definition)| definition.clone())
|
||||
.collect::<Vec<_>>(),
|
||||
request.suppress_subagent_progress_update_tool == Some(true),
|
||||
)?;
|
||||
let proposed_base_revision_id = match base_messages.as_mut() {
|
||||
Some(messages) if !messages.is_empty() => {
|
||||
validate_prompt_root(messages)?;
|
||||
messages.retain(|message| {
|
||||
!(message.role == Role::System && message.origin == Origin::Prompt)
|
||||
});
|
||||
store.import_revision(&conversation_id, messages).await?
|
||||
}
|
||||
Some(_) | None => store.ensure_conversation(&conversation_id).await?,
|
||||
};
|
||||
let base_revision_id = match input_id {
|
||||
Some(input_id) => {
|
||||
store
|
||||
.anchor_input(&conversation_id, &input_id, proposed_base_revision_id)
|
||||
.await?
|
||||
}
|
||||
None => proposed_base_revision_id,
|
||||
};
|
||||
let initial_messages = match (turn_user.as_ref(), event_id) {
|
||||
(Some(user), Some(event_id)) => vec![
|
||||
runtime::compile(
|
||||
event_id,
|
||||
mode,
|
||||
user,
|
||||
&request_context,
|
||||
&action_context,
|
||||
compiler,
|
||||
blob_sync,
|
||||
)
|
||||
.await?,
|
||||
],
|
||||
(None, None) => Vec::new(),
|
||||
_ => {
|
||||
return Err(Error::Protocol(
|
||||
"Cursor action has an incomplete runtime event".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let action = if starts_turn {
|
||||
RunAction::Start
|
||||
} else {
|
||||
let pending_tool_round = match request
|
||||
.conversation_state
|
||||
.as_ref()
|
||||
.map(|state| state.pending_tool_calls.as_slice())
|
||||
.unwrap_or_default()
|
||||
{
|
||||
[] => None,
|
||||
[pending] => Some(projection::decode_pending(pending)?),
|
||||
pending => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"Cursor resume contains {} pending assistant messages",
|
||||
pending.len()
|
||||
)))
|
||||
}
|
||||
};
|
||||
RunAction::Resume { pending_tool_round }
|
||||
};
|
||||
let kind = match (request.subagent_type_name.as_deref(), parent) {
|
||||
(None, _) => RunKind::Root,
|
||||
(Some(name), Some((parent_run_id, parent_tool_call_id))) => RunKind::Subagent {
|
||||
parent_run_id,
|
||||
parent_tool_call_id,
|
||||
kind: model::subagent_kind(name),
|
||||
background: false,
|
||||
},
|
||||
(Some(_), None) => {
|
||||
return Err(Error::Protocol(
|
||||
"subagent Run is missing its parent Run and tool call".into(),
|
||||
));
|
||||
}
|
||||
};
|
||||
let exec = exec_context(request, &request_context, &conversation_id, &model.model_id);
|
||||
Ok((
|
||||
PreparedRun {
|
||||
run_id,
|
||||
conversation_id,
|
||||
kind,
|
||||
model,
|
||||
prompt,
|
||||
selected_subagent_models: model::selected_models(request)?,
|
||||
subagent_model_overrides: model::overrides(request)?,
|
||||
initial_messages,
|
||||
action,
|
||||
base_revision_id,
|
||||
},
|
||||
CursorRunContext {
|
||||
request_id: request_id.into(),
|
||||
mode: mode_number,
|
||||
turn_user,
|
||||
exec,
|
||||
dynamic_tools: dynamic
|
||||
.into_iter()
|
||||
.map(|(name, (wire, _))| (name, wire))
|
||||
.collect(),
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
fn validate_prompt_root(messages: &[CanonicalMessage]) -> Result<()> {
|
||||
let prompts = messages
|
||||
.iter()
|
||||
.filter(|message| message.role == Role::System && message.origin == Origin::Prompt)
|
||||
.collect::<Vec<_>>();
|
||||
let [prompt] = prompts.as_slice() else {
|
||||
return Err(Error::Protocol(format!(
|
||||
"Cursor history contains {} system prompt roots",
|
||||
prompts.len()
|
||||
)));
|
||||
};
|
||||
let MessageContent::Parts { parts } = &prompt.content else {
|
||||
return Err(Error::Protocol(
|
||||
"Cursor system prompt root is not textual content".into(),
|
||||
));
|
||||
};
|
||||
let [ContentPart::Text { .. }] = parts.as_slice() else {
|
||||
return Err(Error::Protocol(
|
||||
"Cursor system prompt root is not one text part".into(),
|
||||
));
|
||||
};
|
||||
Ok(())
|
||||
}
|
||||
|
||||
fn action(request_id: &str, request: &pb::AgentRunRequest) -> Result<ActionProjection> {
|
||||
let mode = request
|
||||
.conversation_state
|
||||
.as_ref()
|
||||
.and_then(|state| state.mode)
|
||||
.unwrap_or(pb::AgentMode::Agent as i32);
|
||||
let Some(action) = request
|
||||
.action
|
||||
.as_ref()
|
||||
.and_then(|action| action.action.as_ref())
|
||||
else {
|
||||
return Ok(ActionProjection {
|
||||
mode,
|
||||
turn_user: None,
|
||||
action_context: String::new(),
|
||||
event_id: None,
|
||||
input_id: None,
|
||||
starts_turn: false,
|
||||
});
|
||||
};
|
||||
match action {
|
||||
pb::conversation_action::Action::UserMessageAction(action) => {
|
||||
let user = action.user_message.as_ref().ok_or_else(|| {
|
||||
Error::Protocol("Cursor user message action has no UserMessage".into())
|
||||
})?;
|
||||
if user.message_id.is_empty() {
|
||||
return Err(Error::Protocol(
|
||||
"Cursor user message action has no message_id".into(),
|
||||
));
|
||||
}
|
||||
let mut context = action
|
||||
.prepend_user_messages
|
||||
.iter()
|
||||
.map(|message| message.text.trim())
|
||||
.filter(|text| !text.is_empty())
|
||||
.map(str::to_string)
|
||||
.collect::<Vec<_>>();
|
||||
context.extend(
|
||||
user.subagent_system_reminder
|
||||
.iter()
|
||||
.filter(|text| !text.is_empty())
|
||||
.cloned(),
|
||||
);
|
||||
Ok(ActionProjection {
|
||||
mode: user.mode,
|
||||
turn_user: Some(user.clone()),
|
||||
action_context: context.join("\n\n"),
|
||||
event_id: Some(format!("run-request:{request_id}")),
|
||||
input_id: Some(format!("cursor:user:{}", user.message_id)),
|
||||
starts_turn: true,
|
||||
})
|
||||
}
|
||||
pb::conversation_action::Action::BackgroundTaskCompletionAction(action) => {
|
||||
let projection = background::project(action, mode)?;
|
||||
Ok(ActionProjection {
|
||||
mode,
|
||||
action_context: projection.context,
|
||||
event_id: Some(format!("run-request:{request_id}")),
|
||||
input_id: None,
|
||||
turn_user: Some(projection.turn_user),
|
||||
starts_turn: true,
|
||||
})
|
||||
}
|
||||
_ => Ok(ActionProjection {
|
||||
mode,
|
||||
turn_user: None,
|
||||
action_context: String::new(),
|
||||
event_id: None,
|
||||
input_id: None,
|
||||
starts_turn: false,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
fn mode_from_proto(mode: i32) -> Result<Mode> {
|
||||
let mode = pb::AgentMode::try_from(mode)
|
||||
.map_err(|_| Error::Protocol(format!("unknown Cursor agent mode: {mode}")))?;
|
||||
match mode {
|
||||
pb::AgentMode::Agent => Ok(Mode::Agent),
|
||||
pb::AgentMode::Ask => Ok(Mode::Ask),
|
||||
pb::AgentMode::Plan => Ok(Mode::Plan),
|
||||
pb::AgentMode::Debug => Ok(Mode::Debug),
|
||||
pb::AgentMode::Multitask => Ok(Mode::Multitask),
|
||||
mode => Err(Error::Protocol(format!(
|
||||
"unsupported Cursor agent mode: {}",
|
||||
mode.as_str_name()
|
||||
))),
|
||||
}
|
||||
}
|
||||
|
||||
fn exec_context(
|
||||
request: &pb::AgentRunRequest,
|
||||
request_context: &pb::RequestContext,
|
||||
conversation_id: &ConversationId,
|
||||
model_id: &str,
|
||||
) -> ExecContext {
|
||||
let subagent_models = request
|
||||
.subagent_model_overrides
|
||||
.iter()
|
||||
.filter_map(|value| {
|
||||
use pb::subagent_model_override::Selection;
|
||||
let selection = match value.selection.as_ref()? {
|
||||
Selection::Model(model) => SubagentModel::Model(model.model_id.clone()),
|
||||
Selection::Inherit(true) => SubagentModel::Model(model_id.into()),
|
||||
Selection::Disabled(true) => SubagentModel::Disabled,
|
||||
Selection::Inherit(false) | Selection::Disabled(false) => return None,
|
||||
};
|
||||
Some((value.subagent_type.clone(), selection))
|
||||
})
|
||||
.collect();
|
||||
ExecContext {
|
||||
conversation_id: conversation_id.to_string(),
|
||||
root_conversation_id: request
|
||||
.conversation_group_id
|
||||
.clone()
|
||||
.unwrap_or_else(|| conversation_id.to_string()),
|
||||
model_id: model_id.into(),
|
||||
subagent_models,
|
||||
terminals_folder: request_context
|
||||
.env
|
||||
.as_ref()
|
||||
.map(|env| env.terminals_folder.clone())
|
||||
.unwrap_or_default(),
|
||||
admin_command_denylist: request_context.admin_command_denylist.clone(),
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn restored_system_root_is_structural_not_bound_to_the_next_model() {
|
||||
let prompt = CanonicalMessage::text(
|
||||
"root",
|
||||
Role::System,
|
||||
Origin::Prompt,
|
||||
"prompt from the previous model",
|
||||
);
|
||||
validate_prompt_root(std::slice::from_ref(&prompt)).unwrap();
|
||||
assert!(validate_prompt_root(&[prompt.clone(), prompt]).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unsupported_cursor_mode_is_not_silently_treated_as_agent() {
|
||||
assert_eq!(
|
||||
mode_from_proto(pb::AgentMode::Agent as i32).unwrap(),
|
||||
Mode::Agent
|
||||
);
|
||||
assert!(mode_from_proto(pb::AgentMode::Project as i32).is_err());
|
||||
assert!(mode_from_proto(99).is_err());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn current_user_message_consumes_the_mode_instead_of_history_mode() {
|
||||
let request = pb::AgentRunRequest {
|
||||
conversation_state: Some(pb::ConversationStateStructure {
|
||||
mode: Some(pb::AgentMode::Agent as i32),
|
||||
..Default::default()
|
||||
}),
|
||||
action: Some(pb::ConversationAction {
|
||||
action: Some(pb::conversation_action::Action::UserMessageAction(
|
||||
pb::UserMessageAction {
|
||||
user_message: Some(pb::UserMessage {
|
||||
text: "explain".into(),
|
||||
message_id: "user-message".into(),
|
||||
mode: pb::AgentMode::Ask as i32,
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
},
|
||||
)),
|
||||
..Default::default()
|
||||
}),
|
||||
..Default::default()
|
||||
};
|
||||
let projection = action("request", &request).unwrap();
|
||||
assert_eq!(projection.mode, pb::AgentMode::Ask as i32);
|
||||
assert_eq!(
|
||||
projection.input_id.as_deref(),
|
||||
Some("cursor:user:user-message")
|
||||
);
|
||||
assert_eq!(mode_from_proto(projection.mode).unwrap(), Mode::Ask);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use chrono::{Offset, Utc};
|
||||
use chrono_tz::Tz;
|
||||
|
||||
use crate::{
|
||||
cursor::{
|
||||
blob_sync::BlobSynchronizer,
|
||||
prompting::{Mode, PromptCompiler},
|
||||
proto::agent::v1 as pb,
|
||||
},
|
||||
model::{CanonicalMessage, MessageContent, Origin, Role},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::{context, images};
|
||||
|
||||
pub async fn compile(
|
||||
event_id: String,
|
||||
mode: Mode,
|
||||
user: &pb::UserMessage,
|
||||
request_context: &pb::RequestContext,
|
||||
action_context: &str,
|
||||
compiler: &PromptCompiler,
|
||||
blobs: &BlobSynchronizer,
|
||||
) -> Result<CanonicalMessage> {
|
||||
let time = Time::now(
|
||||
request_context
|
||||
.env
|
||||
.as_ref()
|
||||
.map(|env| env.time_zone.as_str()),
|
||||
)?;
|
||||
let mut values = BTreeMap::from([
|
||||
(
|
||||
"REQUEST_CONTEXT",
|
||||
section(context::compile_context(request_context, &time.today)),
|
||||
),
|
||||
("OPEN_FILES", section(open_files(user))),
|
||||
(
|
||||
"SELECTED_CONTEXT",
|
||||
section(
|
||||
context::selected_context(user)
|
||||
.filter(|value| !value.is_empty())
|
||||
.map(|value| format!("<selected_context>\n{value}\n</selected_context>"))
|
||||
.unwrap_or_default(),
|
||||
),
|
||||
),
|
||||
("ACTION_CONTEXT", section(action_context.to_string())),
|
||||
("TIMESTAMP", time.timestamp),
|
||||
("USER_QUERY", user.text.clone()),
|
||||
("DEBUG_SERVER_ENDPOINT", String::new()),
|
||||
("DEBUG_LOG_PATH", String::new()),
|
||||
("DEBUG_SESSION_ID", String::new()),
|
||||
]);
|
||||
if let Some(debug) = &request_context.debug_mode_config {
|
||||
values.insert("DEBUG_SERVER_ENDPOINT", debug.server_endpoint.clone());
|
||||
values.insert("DEBUG_LOG_PATH", debug.log_path.clone());
|
||||
values.insert("DEBUG_SESSION_ID", debug.session_id.clone());
|
||||
}
|
||||
let text = compiler.runtime_message(mode, &values)?;
|
||||
Ok(CanonicalMessage {
|
||||
message_id: format!("runtime:{event_id}"),
|
||||
role: Role::User,
|
||||
origin: Origin::Runtime,
|
||||
content: MessageContent::Parts {
|
||||
parts: images::parts(user, text, blobs).await?,
|
||||
},
|
||||
runtime_event_id: Some(event_id),
|
||||
})
|
||||
}
|
||||
|
||||
fn section(value: String) -> String {
|
||||
let value = value.trim();
|
||||
if value.is_empty() {
|
||||
String::new()
|
||||
} else {
|
||||
format!("{value}\n\n")
|
||||
}
|
||||
}
|
||||
|
||||
fn open_files(user: &pb::UserMessage) -> String {
|
||||
let Some(ide) = user
|
||||
.selected_context
|
||||
.as_ref()
|
||||
.and_then(|selected| selected.invocation_context.as_ref())
|
||||
.and_then(|invocation| invocation.data.as_ref())
|
||||
.and_then(|data| match data {
|
||||
pb::invocation_context::Data::IdeState(ide) => Some(ide),
|
||||
_ => None,
|
||||
})
|
||||
else {
|
||||
return String::new();
|
||||
};
|
||||
if ide.visible_files.is_empty() && ide.recently_viewed_files.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
|
||||
let mut output = String::from("<open_and_recently_viewed_files>\n");
|
||||
if !ide.recently_viewed_files.is_empty() {
|
||||
output.push_str("Recently viewed files (recent at the top, oldest at the bottom):\n");
|
||||
for file in &ide.recently_viewed_files {
|
||||
output.push_str(&format!(
|
||||
"- {} (total lines: {})\n",
|
||||
file.path, file.total_lines
|
||||
));
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
if !ide.visible_files.is_empty() {
|
||||
output.push_str("Files that are currently open and visible in the user's IDE:\n");
|
||||
for (index, file) in ide.visible_files.iter().enumerate() {
|
||||
output.push_str(&format!("- {} (", file.path));
|
||||
if index == 0 {
|
||||
output.push_str("currently focused file");
|
||||
if let Some(cursor) = &file.cursor_position {
|
||||
output.push_str(&format!(", cursor is on line {}", cursor.line));
|
||||
}
|
||||
output.push_str(&format!(", total lines: {}", file.total_lines));
|
||||
} else {
|
||||
output.push_str(&format!("total lines: {}", file.total_lines));
|
||||
}
|
||||
output.push_str(")\n");
|
||||
}
|
||||
output.push('\n');
|
||||
}
|
||||
output.push_str(
|
||||
"Note: these files may or may not be relevant to the current conversation. Use the read file tool if you need to get the contents of some of them.\n</open_and_recently_viewed_files>",
|
||||
);
|
||||
output
|
||||
}
|
||||
|
||||
struct Time {
|
||||
timestamp: String,
|
||||
today: String,
|
||||
}
|
||||
|
||||
impl Time {
|
||||
fn now(time_zone: Option<&str>) -> Result<Self> {
|
||||
let zone = match time_zone.filter(|value| !value.is_empty()) {
|
||||
Some(value) => value
|
||||
.parse::<Tz>()
|
||||
.map_err(|_| Error::Protocol(format!("invalid Cursor time zone: {value}")))?,
|
||||
None => chrono_tz::UTC,
|
||||
};
|
||||
let now = Utc::now().with_timezone(&zone);
|
||||
let offset = now.offset().fix().local_minus_utc();
|
||||
let sign = if offset < 0 { '-' } else { '+' };
|
||||
let offset = offset.unsigned_abs();
|
||||
let hours = offset / 3600;
|
||||
let minutes = (offset % 3600) / 60;
|
||||
let utc = if minutes == 0 {
|
||||
format!("UTC{sign}{hours}")
|
||||
} else {
|
||||
format!("UTC{sign}{hours}:{minutes:02}")
|
||||
};
|
||||
Ok(Self {
|
||||
timestamp: format!("{} ({utc})", now.format("%A, %b %-d, %Y, %-I:%M %p")),
|
||||
today: now.format("%A %b %-d,\n%Y").to_string(),
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -6,9 +6,9 @@ use bytes::Bytes;
|
||||
use tokio_stream::wrappers::UnboundedReceiverStream;
|
||||
use tokio_stream::StreamExt;
|
||||
|
||||
use crate::{run::RunRegistry, Result};
|
||||
use crate::{cursor::CursorSessionRegistry, Result};
|
||||
|
||||
pub async fn stream(registry: &RunRegistry, request_id: &str) -> Result<Response<Body>> {
|
||||
pub async fn stream(registry: &CursorSessionRegistry, request_id: &str) -> Result<Response<Body>> {
|
||||
let receiver = registry.get_or_create(request_id).await?.subscribe();
|
||||
let body_stream =
|
||||
UnboundedReceiverStream::new(receiver).map(Ok::<Bytes, std::convert::Infallible>);
|
||||
|
||||
@@ -0,0 +1,496 @@
|
||||
use std::collections::{BTreeMap, HashMap, HashSet, VecDeque};
|
||||
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::{
|
||||
client::{ClientCommand, ClientEvent, ClientSession, CommitCause},
|
||||
cursor::{
|
||||
checkpoint::{
|
||||
worker::{CheckpointJob, CheckpointKind, CheckpointWorker, FinalCheckpoints},
|
||||
CheckpointBuilder,
|
||||
},
|
||||
interaction,
|
||||
presentation::Presentation,
|
||||
proto::agent::v1 as pb,
|
||||
request::CursorRunContext,
|
||||
tools::{
|
||||
codec,
|
||||
result::{ToolCompletion, ToolResultReceiver},
|
||||
runtime::CursorToolRuntime,
|
||||
stream::ToolCallStream,
|
||||
ToolBatchState, ToolDispatcher,
|
||||
},
|
||||
},
|
||||
model::{ToolCall, ToolRoundId, Usage},
|
||||
run::{RunFailure, RunOutcome},
|
||||
store::{Store, ToolRoundStatus},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::CursorSessionHandle;
|
||||
|
||||
pub struct CursorSession {
|
||||
handle: CursorSessionHandle,
|
||||
store: Store,
|
||||
context: CursorRunContext,
|
||||
core: ClientSession,
|
||||
tools: ToolDispatcher,
|
||||
results: ToolResultReceiver,
|
||||
checkpoint: CheckpointBuilder,
|
||||
tool_runtime: CursorToolRuntime,
|
||||
}
|
||||
|
||||
pub(crate) struct CursorSessionRuntime {
|
||||
pub tools: ToolDispatcher,
|
||||
pub results: ToolResultReceiver,
|
||||
pub checkpoint: CheckpointBuilder,
|
||||
pub tool_runtime: CursorToolRuntime,
|
||||
}
|
||||
|
||||
impl CursorSession {
|
||||
pub(crate) fn new(
|
||||
handle: CursorSessionHandle,
|
||||
store: Store,
|
||||
context: CursorRunContext,
|
||||
core: ClientSession,
|
||||
runtime: CursorSessionRuntime,
|
||||
) -> Self {
|
||||
Self {
|
||||
handle,
|
||||
store,
|
||||
context,
|
||||
core,
|
||||
tools: runtime.tools,
|
||||
results: runtime.results,
|
||||
checkpoint: runtime.checkpoint,
|
||||
tool_runtime: runtime.tool_runtime,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn run(mut self) -> Result<()> {
|
||||
let mut worker = CheckpointWorker::spawn(
|
||||
self.store.clone(),
|
||||
self.checkpoint.clone(),
|
||||
self.handle.clone(),
|
||||
self.context.mode,
|
||||
);
|
||||
let mut checkpoint_worker_open = true;
|
||||
let mut calls = BTreeMap::<usize, ToolCall>::new();
|
||||
let mut streams = BTreeMap::<usize, ToolCallStream>::new();
|
||||
let mut completions = HashMap::<String, ToolCompletion>::new();
|
||||
let mut completed = HashSet::<String>::new();
|
||||
let mut response_text = String::new();
|
||||
let mut response_thinking = String::new();
|
||||
let mut active_round = None::<ToolRoundId>;
|
||||
let mut final_checkpoint = None::<FinalCheckpoints>;
|
||||
let mut turn_usage = None::<Usage>;
|
||||
let mut context_tokens = None::<u64>;
|
||||
let mut ready = VecDeque::new();
|
||||
let mut presentation = Presentation::default();
|
||||
|
||||
loop {
|
||||
let input = if let Some(completion) = ready.pop_front() {
|
||||
Input::Completion(completion)
|
||||
} else {
|
||||
tokio::select! {
|
||||
event = self.core.events.recv() => Input::Event(event),
|
||||
completion = self.results.recv() => Input::CompletionResult(completion),
|
||||
failure = worker.failures.recv(), if checkpoint_worker_open => Input::CheckpointFailure(failure),
|
||||
}
|
||||
};
|
||||
match input {
|
||||
Input::CheckpointFailure(Some(error)) => return Err(error),
|
||||
Input::CheckpointFailure(None) => {
|
||||
checkpoint_worker_open = false;
|
||||
}
|
||||
Input::Completion(completion) => {
|
||||
self.forward_completion(completion, &mut completions)
|
||||
.await?;
|
||||
}
|
||||
Input::CompletionResult(Some(result)) => {
|
||||
self.forward_completion(result?, &mut completions).await?;
|
||||
}
|
||||
Input::CompletionResult(None) => {
|
||||
return Err(Error::Protocol("tool result channel closed".into()));
|
||||
}
|
||||
Input::Event(None) => {
|
||||
worker.abort();
|
||||
return Err(Error::Protocol("core event channel closed".into()));
|
||||
}
|
||||
Input::Event(Some(event)) => match event {
|
||||
ClientEvent::TextStart => {}
|
||||
ClientEvent::TextEnd => presentation.finish_text(),
|
||||
ClientEvent::TextDelta(delta) => {
|
||||
response_text.push_str(&delta);
|
||||
presentation.text_delta(&delta);
|
||||
self.emit_model_event(crate::provider::ModelEvent::TextDelta(delta), "")?;
|
||||
}
|
||||
ClientEvent::ThinkingStart => {}
|
||||
ClientEvent::ThinkingDelta(delta) => {
|
||||
response_thinking.push_str(&delta);
|
||||
presentation.thinking_delta(&delta);
|
||||
self.emit_model_event(
|
||||
crate::provider::ModelEvent::ThinkingDelta(delta),
|
||||
"",
|
||||
)?;
|
||||
}
|
||||
ClientEvent::ThinkingEnd { duration } => {
|
||||
presentation.finish_thinking(duration);
|
||||
self.handle
|
||||
.emit(&interaction::thinking_completed(duration))?;
|
||||
}
|
||||
ClientEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
name,
|
||||
model_call_id,
|
||||
} => {
|
||||
let call = ToolCall {
|
||||
index,
|
||||
call_id: call_id.clone(),
|
||||
model_call_id: model_call_id.clone(),
|
||||
name: name.clone(),
|
||||
arguments_text: String::new(),
|
||||
arguments: serde_json::Value::Null,
|
||||
};
|
||||
self.emit_model_event(
|
||||
crate::provider::ModelEvent::ToolCallStart {
|
||||
index,
|
||||
call_id,
|
||||
name: name.clone(),
|
||||
},
|
||||
&model_call_id,
|
||||
)?;
|
||||
streams.insert(index, ToolCallStream::new(&name));
|
||||
calls.insert(index, call);
|
||||
}
|
||||
ClientEvent::ToolCallArgumentsDelta { index, delta } => {
|
||||
let call = calls.get_mut(&index).ok_or_else(|| {
|
||||
Error::Protocol(format!("unknown streaming tool index: {index}"))
|
||||
})?;
|
||||
call.arguments_text.push_str(&delta);
|
||||
let stream = streams.get_mut(&index).ok_or_else(|| {
|
||||
Error::Protocol(format!("missing Cursor tool stream: {index}"))
|
||||
})?;
|
||||
for message in stream.arguments_delta(call, &delta)? {
|
||||
self.handle.emit(&message)?;
|
||||
}
|
||||
}
|
||||
ClientEvent::ToolCallEnd { index } => {
|
||||
let call = calls.get_mut(&index).ok_or_else(|| {
|
||||
Error::Protocol(format!("unknown completed tool index: {index}"))
|
||||
})?;
|
||||
call.arguments = serde_json::from_str(&call.arguments_text)?;
|
||||
}
|
||||
ClientEvent::Usage(usage) => {
|
||||
if let Some(output_tokens) = usage.output_tokens {
|
||||
self.handle.emit(&interaction::token_delta(output_tokens))?;
|
||||
}
|
||||
context_tokens = usage
|
||||
.input_tokens
|
||||
.zip(usage.output_tokens)
|
||||
.and_then(|(input, output)| input.checked_add(output));
|
||||
match &mut turn_usage {
|
||||
Some(total) => *total += usage,
|
||||
None => turn_usage = Some(usage),
|
||||
}
|
||||
}
|
||||
ClientEvent::ExecuteToolRound {
|
||||
round_id,
|
||||
calls: round_calls,
|
||||
} => {
|
||||
active_round = Some(round_id);
|
||||
for dispatched in self
|
||||
.tools
|
||||
.start_batch(
|
||||
&round_calls,
|
||||
ToolBatchState {
|
||||
completed: &completed,
|
||||
started: &HashSet::new(),
|
||||
response_text: &response_text,
|
||||
response_thinking: &response_thinking,
|
||||
},
|
||||
&self
|
||||
.store
|
||||
.load_current_messages(&crate::model::ConversationId::new(
|
||||
&self.context.exec.conversation_id,
|
||||
))
|
||||
.await?,
|
||||
&self.context.dynamic_tools,
|
||||
&self.context.exec,
|
||||
)
|
||||
.await?
|
||||
{
|
||||
for message in dispatched.messages {
|
||||
self.handle.emit(&message)?;
|
||||
}
|
||||
if let Some(completion) = dispatched.completion {
|
||||
ready.push_back(completion);
|
||||
}
|
||||
}
|
||||
response_text.clear();
|
||||
response_thinking.clear();
|
||||
calls.clear();
|
||||
streams.clear();
|
||||
}
|
||||
ClientEvent::StateCommitted(state) => {
|
||||
if let CommitCause::ToolRoundStarted(round_id) = &state.cause {
|
||||
active_round = Some(round_id.clone());
|
||||
}
|
||||
let mut tool_round_settled = false;
|
||||
if let CommitCause::ToolResult { call_id } = &state.cause {
|
||||
let completion = completions.remove(call_id).ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"core committed a tool result without typed Cursor state: {call_id}"
|
||||
))
|
||||
})?;
|
||||
let snapshot = self
|
||||
.store
|
||||
.tool_round(active_round.as_ref().ok_or_else(|| {
|
||||
Error::Protocol("tool commit has no active round".into())
|
||||
})?)
|
||||
.await?
|
||||
.ok_or_else(|| {
|
||||
Error::Store("active tool round disappeared".into())
|
||||
})?;
|
||||
let call = snapshot
|
||||
.calls
|
||||
.iter()
|
||||
.find(|call| call.call_id == *call_id)
|
||||
.ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"committed call is absent from tool round: {call_id}"
|
||||
))
|
||||
})?;
|
||||
self.handle
|
||||
.emit(&interaction::tool_completed(call, &completion))?;
|
||||
presentation.tool_completed(&completion);
|
||||
completed.insert(call_id.clone());
|
||||
tool_round_settled = snapshot.status == ToolRoundStatus::Settled;
|
||||
}
|
||||
let final_turn = state.cause == CommitCause::FinalTurn;
|
||||
if final_turn {
|
||||
if !state.barrier.is_required() {
|
||||
return Err(Error::Protocol(
|
||||
"final state has no completion barrier".into(),
|
||||
));
|
||||
}
|
||||
let (sender, receiver) = oneshot::channel();
|
||||
worker
|
||||
.jobs
|
||||
.send(CheckpointJob {
|
||||
kind: CheckpointKind::Final {
|
||||
revision_id: state.revision_id,
|
||||
result: sender,
|
||||
},
|
||||
presentation: presentation.take(),
|
||||
context_tokens,
|
||||
ready: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::Protocol("checkpoint worker closed".into()))?;
|
||||
match receiver
|
||||
.await
|
||||
.map_err(|_| Error::Protocol("checkpoint worker stopped".into()))?
|
||||
{
|
||||
Ok(checkpoints) => {
|
||||
final_checkpoint = Some(checkpoints);
|
||||
state.barrier.complete(Ok(()));
|
||||
}
|
||||
Err(error) => {
|
||||
state.barrier.complete(Err(error.to_string()));
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
} else if let CommitCause::ToolRoundStarted(round_id) = &state.cause {
|
||||
worker
|
||||
.jobs
|
||||
.send(CheckpointJob {
|
||||
kind: CheckpointKind::ToolStarted {
|
||||
round_id: round_id.clone(),
|
||||
stable_revision_id: state.revision_id,
|
||||
},
|
||||
presentation: presentation.take(),
|
||||
context_tokens,
|
||||
ready: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::Protocol("checkpoint worker closed".into()))?;
|
||||
} else if tool_round_settled {
|
||||
if !state.barrier.is_required() {
|
||||
return Err(Error::Protocol(
|
||||
"settled tool round has no completion barrier".into(),
|
||||
));
|
||||
}
|
||||
let (ready, published) = oneshot::channel();
|
||||
worker
|
||||
.jobs
|
||||
.send(CheckpointJob {
|
||||
kind: CheckpointKind::ToolSettled(state.revision_id),
|
||||
presentation: presentation.take(),
|
||||
context_tokens,
|
||||
ready: Some(ready),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::Protocol("checkpoint worker closed".into()))?;
|
||||
let result = published
|
||||
.await
|
||||
.map_err(|_| Error::Protocol("checkpoint worker stopped".into()))?
|
||||
.map_err(Error::Protocol);
|
||||
match result {
|
||||
Ok(()) => state.barrier.complete(Ok(())),
|
||||
Err(error) => {
|
||||
state.barrier.complete(Err(error.to_string()));
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
active_round = None;
|
||||
self.tool_runtime.clear_completed().await;
|
||||
} else if !matches!(&state.cause, CommitCause::ToolResult { .. })
|
||||
&& active_round.is_some()
|
||||
{
|
||||
let round_id = active_round.clone().ok_or_else(|| {
|
||||
Error::Protocol("active tool round disappeared".into())
|
||||
})?;
|
||||
worker
|
||||
.jobs
|
||||
.send(CheckpointJob {
|
||||
kind: CheckpointKind::ToolStarted {
|
||||
round_id,
|
||||
stable_revision_id: state.revision_id,
|
||||
},
|
||||
presentation: presentation.take(),
|
||||
context_tokens,
|
||||
ready: None,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::Protocol("checkpoint worker closed".into()))?;
|
||||
} else if !matches!(&state.cause, CommitCause::ToolResult { .. }) {
|
||||
let requires_ready = state.barrier.is_required();
|
||||
let (ready, published) = oneshot::channel();
|
||||
worker
|
||||
.jobs
|
||||
.send(CheckpointJob {
|
||||
kind: CheckpointKind::Settled(state.revision_id),
|
||||
presentation: presentation.take(),
|
||||
context_tokens,
|
||||
ready: requires_ready.then_some(ready),
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::Protocol("checkpoint worker closed".into()))?;
|
||||
if requires_ready {
|
||||
let result = published
|
||||
.await
|
||||
.map_err(|_| {
|
||||
Error::Protocol("checkpoint worker stopped".into())
|
||||
})?
|
||||
.map_err(Error::Protocol);
|
||||
match result {
|
||||
Ok(()) => state.barrier.complete(Ok(())),
|
||||
Err(error) => {
|
||||
state.barrier.complete(Err(error.to_string()));
|
||||
return Err(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
ClientEvent::Ended(outcome) => {
|
||||
return match outcome {
|
||||
RunOutcome::Completed => {
|
||||
let checkpoints = final_checkpoint.take().ok_or_else(|| {
|
||||
Error::Protocol("Completed without final state".into())
|
||||
})?;
|
||||
self.handle.emit(&interaction::turn_ended(turn_usage))?;
|
||||
self.checkpoint
|
||||
.publish(&self.handle, &checkpoints.staged)
|
||||
.await?;
|
||||
self.checkpoint
|
||||
.publish(&self.handle, &checkpoints.settled)
|
||||
.await?;
|
||||
self.handle.emit(&pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::ConversationCheckpointUpdate(checkpoints.settled)),
|
||||
})?;
|
||||
crate::cursor::lifecycle::finish_success(&self.handle);
|
||||
Ok(())
|
||||
}
|
||||
RunOutcome::Cancelled => {
|
||||
worker.abort();
|
||||
self.abort_execs().await;
|
||||
crate::cursor::lifecycle::cancel(&self.handle)
|
||||
}
|
||||
RunOutcome::Failed(failure) => {
|
||||
worker.abort();
|
||||
self.abort_execs().await;
|
||||
crate::cursor::lifecycle::fail(&self.handle, &cursor_error(failure))
|
||||
}
|
||||
};
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn abort_execs(&self) {
|
||||
for id in self.tool_runtime.drain_running().await {
|
||||
let _ = self.handle.emit(&codec::abort(id));
|
||||
}
|
||||
}
|
||||
|
||||
async fn forward_completion(
|
||||
&self,
|
||||
completion: ToolCompletion,
|
||||
completions: &mut HashMap<String, ToolCompletion>,
|
||||
) -> Result<()> {
|
||||
let result = completion.result();
|
||||
if result.call_id.is_empty() {
|
||||
return Err(Error::Protocol("tool result call_id is empty".into()));
|
||||
}
|
||||
if completions
|
||||
.insert(result.call_id.clone(), completion.clone())
|
||||
.is_some()
|
||||
{
|
||||
return Err(Error::Protocol(format!(
|
||||
"duplicate tool result call_id: {}",
|
||||
result.call_id
|
||||
)));
|
||||
}
|
||||
self.core
|
||||
.commands
|
||||
.send(ClientCommand::ToolResult {
|
||||
call_id: result.call_id.clone(),
|
||||
content: result.content.clone(),
|
||||
is_error: result.is_error,
|
||||
})
|
||||
.await
|
||||
.map_err(|_| Error::RunNotFound(self.context.request_id.clone()))
|
||||
}
|
||||
|
||||
fn emit_model_event(
|
||||
&self,
|
||||
event: crate::provider::ModelEvent,
|
||||
model_call_id: &str,
|
||||
) -> Result<()> {
|
||||
if let Some(message) = interaction::response_event(&event, model_call_id)? {
|
||||
self.handle.emit(&message)?;
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
enum Input {
|
||||
Event(Option<ClientEvent>),
|
||||
Completion(ToolCompletion),
|
||||
CompletionResult(Option<Result<ToolCompletion>>),
|
||||
CheckpointFailure(Option<Error>),
|
||||
}
|
||||
|
||||
fn cursor_error(failure: RunFailure) -> Error {
|
||||
match failure {
|
||||
RunFailure::Protocol(message) => Error::Protocol(message),
|
||||
RunFailure::Provider(message) => Error::Provider(message),
|
||||
RunFailure::Store(message) => Error::Store(message),
|
||||
RunFailure::Client(message) => Error::Protocol(message),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,237 @@
|
||||
use std::{
|
||||
collections::HashMap,
|
||||
sync::{Arc, OnceLock},
|
||||
};
|
||||
|
||||
use bytes::Bytes;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use tokio_util::sync::CancellationToken;
|
||||
|
||||
use crate::{
|
||||
cursor::prompting::PromptCompiler,
|
||||
cursor::{blob_sync::BlobSynchronizer, proto::agent::v1 as pb},
|
||||
provider::Provider,
|
||||
run::RunRegistry,
|
||||
store::Store,
|
||||
Result,
|
||||
};
|
||||
|
||||
use super::{
|
||||
actor::{CursorActor, RunDependencies},
|
||||
CursorCommand,
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CursorSessionHandle {
|
||||
request_id: String,
|
||||
commands: mpsc::Sender<CursorCommand>,
|
||||
output: Arc<OutputHub>,
|
||||
cancellation: CancellationToken,
|
||||
parent: Arc<OnceLock<CursorParent>>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Eq)]
|
||||
pub struct CursorParent {
|
||||
pub run_id: String,
|
||||
pub tool_call_id: String,
|
||||
}
|
||||
|
||||
impl CursorSessionHandle {
|
||||
pub fn request_id(&self) -> &str {
|
||||
&self.request_id
|
||||
}
|
||||
pub fn subscribe(&self) -> mpsc::UnboundedReceiver<Bytes> {
|
||||
self.output.subscribe()
|
||||
}
|
||||
pub async fn command(&self, command: CursorCommand) -> Result<()> {
|
||||
self.commands
|
||||
.send(command)
|
||||
.await
|
||||
.map_err(|_| crate::Error::RunNotFound(self.request_id.clone()))
|
||||
}
|
||||
pub fn emit_frame(&self, frame: Bytes) {
|
||||
self.output.emit(frame);
|
||||
}
|
||||
pub fn emit(&self, message: &pb::AgentServerMessage) -> Result<()> {
|
||||
self.emit_frame(crate::cursor::connect::encode_message(message)?);
|
||||
Ok(())
|
||||
}
|
||||
pub fn cancel(&self) {
|
||||
self.cancellation.cancel();
|
||||
}
|
||||
pub fn close_output(&self) {
|
||||
self.output.close();
|
||||
}
|
||||
pub fn cancellation(&self) -> CancellationToken {
|
||||
self.cancellation.clone()
|
||||
}
|
||||
pub fn set_parent(&self, parent: CursorParent) -> Result<()> {
|
||||
if parent.run_id.is_empty() || parent.tool_call_id.is_empty() {
|
||||
return Err(crate::Error::Protocol(
|
||||
"Cursor parent run and tool call ids are required".into(),
|
||||
));
|
||||
}
|
||||
if self.parent.get().is_some_and(|current| current != &parent) {
|
||||
return Err(crate::Error::Protocol(format!(
|
||||
"conflicting parent ids for request {}",
|
||||
self.request_id
|
||||
)));
|
||||
}
|
||||
let _ = self.parent.set(parent);
|
||||
Ok(())
|
||||
}
|
||||
pub fn parent(&self) -> Option<&CursorParent> {
|
||||
self.parent.get()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OutputHub {
|
||||
state: parking_lot::Mutex<OutputState>,
|
||||
closed: tokio::sync::Notify,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
struct OutputState {
|
||||
history: Vec<Bytes>,
|
||||
subscribers: Vec<mpsc::UnboundedSender<Bytes>>,
|
||||
closed: bool,
|
||||
}
|
||||
|
||||
impl OutputHub {
|
||||
fn emit(&self, frame: Bytes) {
|
||||
let mut state = self.state.lock();
|
||||
if state.closed {
|
||||
return;
|
||||
}
|
||||
state.history.push(frame.clone());
|
||||
state
|
||||
.subscribers
|
||||
.retain(|subscriber| subscriber.send(frame.clone()).is_ok());
|
||||
}
|
||||
|
||||
fn subscribe(&self) -> mpsc::UnboundedReceiver<Bytes> {
|
||||
let (sender, receiver) = mpsc::unbounded_channel();
|
||||
let mut state = self.state.lock();
|
||||
for frame in &state.history {
|
||||
let _ = sender.send(frame.clone());
|
||||
}
|
||||
if !state.closed {
|
||||
state.subscribers.push(sender);
|
||||
}
|
||||
receiver
|
||||
}
|
||||
|
||||
fn close(&self) {
|
||||
let mut state = self.state.lock();
|
||||
state.closed = true;
|
||||
state.subscribers.clear();
|
||||
drop(state);
|
||||
self.closed.notify_waiters();
|
||||
}
|
||||
|
||||
async fn wait_closed(&self) {
|
||||
loop {
|
||||
let notified = self.closed.notified();
|
||||
if self.state.lock().closed {
|
||||
return;
|
||||
}
|
||||
notified.await;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct CursorSessionRegistry {
|
||||
inner: Arc<RegistryInner>,
|
||||
}
|
||||
|
||||
struct RegistryInner {
|
||||
runs: Mutex<HashMap<String, CursorSessionHandle>>,
|
||||
run_registry: RunRegistry,
|
||||
store: Store,
|
||||
provider: Arc<dyn Provider>,
|
||||
compiler: PromptCompiler,
|
||||
}
|
||||
|
||||
impl CursorSessionRegistry {
|
||||
pub fn store(&self) -> &Store {
|
||||
&self.inner.store
|
||||
}
|
||||
|
||||
pub fn new(
|
||||
store: Store,
|
||||
provider: Arc<dyn Provider>,
|
||||
compiler: PromptCompiler,
|
||||
run_registry: RunRegistry,
|
||||
) -> Self {
|
||||
Self {
|
||||
inner: Arc::new(RegistryInner {
|
||||
runs: Mutex::new(HashMap::new()),
|
||||
run_registry,
|
||||
store,
|
||||
provider,
|
||||
compiler,
|
||||
}),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_or_create(&self, request_id: &str) -> Result<CursorSessionHandle> {
|
||||
if let Some(handle) = self.inner.runs.lock().await.get(request_id).cloned() {
|
||||
return Ok(handle);
|
||||
}
|
||||
let (commands, receiver) = mpsc::channel(128);
|
||||
let output = Arc::new(OutputHub::default());
|
||||
let cancellation = CancellationToken::new();
|
||||
let handle = CursorSessionHandle {
|
||||
request_id: request_id.into(),
|
||||
commands,
|
||||
output,
|
||||
cancellation,
|
||||
parent: Arc::new(OnceLock::new()),
|
||||
};
|
||||
let mut runs = self.inner.runs.lock().await;
|
||||
if let Some(existing) = runs.get(request_id).cloned() {
|
||||
return Ok(existing);
|
||||
}
|
||||
runs.insert(request_id.into(), handle.clone());
|
||||
drop(runs);
|
||||
let blob_sync =
|
||||
BlobSynchronizer::new(request_id.into(), self.inner.store.clone(), handle.clone());
|
||||
CursorActor::spawn(
|
||||
handle.clone(),
|
||||
receiver,
|
||||
RunDependencies {
|
||||
store: self.inner.store.clone(),
|
||||
provider: self.inner.provider.clone(),
|
||||
compiler: self.inner.compiler.clone(),
|
||||
run_registry: self.inner.run_registry.clone(),
|
||||
},
|
||||
blob_sync,
|
||||
0,
|
||||
);
|
||||
let registry = Arc::downgrade(&self.inner);
|
||||
let request_id = request_id.to_string();
|
||||
let output = handle.output.clone();
|
||||
tokio::spawn(async move {
|
||||
output.wait_closed().await;
|
||||
let Some(registry) = registry.upgrade() else {
|
||||
return;
|
||||
};
|
||||
registry.runs.lock().await.remove(&request_id);
|
||||
});
|
||||
Ok(handle)
|
||||
}
|
||||
|
||||
pub async fn shutdown(&self) {
|
||||
let handles = {
|
||||
let mut runs = self.inner.runs.lock().await;
|
||||
runs.drain().map(|(_, handle)| handle).collect::<Vec<_>>()
|
||||
};
|
||||
self.inner.run_registry.shutdown().await;
|
||||
for handle in handles {
|
||||
handle.cancel();
|
||||
let _ = crate::cursor::lifecycle::cancel(&handle);
|
||||
}
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,318 +0,0 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
use crate::{
|
||||
model::{CanonicalMessage, MessageContent, Origin, Role, ToolCall},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::{
|
||||
exec, interaction,
|
||||
pending::{ExecContext, PendingClientTools, PendingExecRegistry},
|
||||
proto::agent::v1 as pb,
|
||||
tool_result::{self, ToolCompletion},
|
||||
};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToolDispatcher {
|
||||
pending_execs: PendingExecRegistry,
|
||||
pending_interactions: PendingClientTools,
|
||||
}
|
||||
|
||||
pub struct DispatchedTool {
|
||||
pub messages: Vec<pb::AgentServerMessage>,
|
||||
pub completion: Option<ToolCompletion>,
|
||||
}
|
||||
|
||||
pub enum ClientToolEvent {
|
||||
Message(Box<pb::AgentServerMessage>),
|
||||
Completed(Box<ToolCompletion>),
|
||||
}
|
||||
|
||||
impl ToolDispatcher {
|
||||
pub fn new(
|
||||
pending_execs: PendingExecRegistry,
|
||||
pending_interactions: PendingClientTools,
|
||||
) -> Self {
|
||||
Self {
|
||||
pending_execs,
|
||||
pending_interactions,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn start_batch(
|
||||
&self,
|
||||
calls: &[ToolCall],
|
||||
completed: &HashSet<String>,
|
||||
messages: &[CanonicalMessage],
|
||||
response_text: &str,
|
||||
response_thinking: &str,
|
||||
dynamic_mcp: &BTreeMap<String, pb::McpToolDefinition>,
|
||||
context: &ExecContext,
|
||||
) -> Result<Vec<DispatchedTool>> {
|
||||
let first_tool_index = current_turn_step_count(messages)
|
||||
+ usize::from(!response_thinking.is_empty())
|
||||
+ usize::from(!response_text.is_empty())
|
||||
+ 1;
|
||||
let mut dispatched = Vec::with_capacity(calls.len() - completed.len().min(calls.len()));
|
||||
for (position, call) in calls.iter().enumerate() {
|
||||
if completed.contains(&call.call_id) {
|
||||
continue;
|
||||
}
|
||||
dispatched.push(
|
||||
self.start(call, first_tool_index + position, dynamic_mcp, context)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
Ok(dispatched)
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
call: &ToolCall,
|
||||
message_index: usize,
|
||||
dynamic_mcp: &BTreeMap<String, pb::McpToolDefinition>,
|
||||
context: &ExecContext,
|
||||
) -> Result<DispatchedTool> {
|
||||
let mut messages = vec![interaction::tool_started(call)?];
|
||||
let completion = if let Some(definition) = dynamic_mcp.get(&call.name) {
|
||||
let id = self.pending_execs.reserve(call, context).await?;
|
||||
messages.push(exec::mcp_request(id, call, definition)?);
|
||||
None
|
||||
} else {
|
||||
match normalized(&call.name).as_str() {
|
||||
"shell"
|
||||
| "forcebackgroundshell"
|
||||
| "read"
|
||||
| "write"
|
||||
| "delete"
|
||||
| "grep"
|
||||
| "glob"
|
||||
| "ls"
|
||||
| "readlints"
|
||||
| "patchedit"
|
||||
| "writeshellstdin"
|
||||
| "task"
|
||||
| "callmcptool"
|
||||
| "fetchmcpresource" => {
|
||||
let id = self.pending_execs.reserve(call, context).await?;
|
||||
messages.push(exec::request(id, call, context)?);
|
||||
None
|
||||
}
|
||||
"askquestion" | "websearch" | "webfetch" | "switchmode" | "createplan"
|
||||
| "generateimage" => {
|
||||
let id = self.pending_interactions.reserve(call, context).await?;
|
||||
messages.push(interaction::tool_query(id, call)?);
|
||||
None
|
||||
}
|
||||
"todowrite" | "communicateupdate" => Some(tool_result::local(call, message_index)?),
|
||||
_ => return Err(Error::Protocol(format!("unsupported tool: {}", call.name))),
|
||||
}
|
||||
};
|
||||
Ok(DispatchedTool {
|
||||
messages,
|
||||
completion,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn interaction_response(
|
||||
&self,
|
||||
response: &pb::InteractionResponse,
|
||||
) -> Result<ClientToolEvent> {
|
||||
let pending = self
|
||||
.pending_interactions
|
||||
.take(response.id)
|
||||
.await
|
||||
.ok_or_else(|| {
|
||||
Error::Protocol(format!("unknown InteractionResponse id: {}", response.id))
|
||||
})?;
|
||||
if normalized(&pending.call.name) == "webfetch"
|
||||
&& matches!(
|
||||
response.result.as_ref(),
|
||||
Some(pb::interaction_response::Result::WebFetchRequestResponse(
|
||||
pb::WebFetchRequestResponse {
|
||||
result: Some(pb::web_fetch_request_response::Result::Approved(_)),
|
||||
}
|
||||
))
|
||||
)
|
||||
{
|
||||
let id = self
|
||||
.pending_execs
|
||||
.reserve(&pending.call, &pending.context)
|
||||
.await?;
|
||||
return Ok(ClientToolEvent::Message(Box::new(exec::request(
|
||||
id,
|
||||
&pending.call,
|
||||
&pending.context,
|
||||
)?)));
|
||||
}
|
||||
Ok(ClientToolEvent::Completed(Box::new(
|
||||
tool_result::from_interaction(pending, response)?,
|
||||
)))
|
||||
}
|
||||
}
|
||||
|
||||
fn current_turn_step_count(messages: &[CanonicalMessage]) -> usize {
|
||||
let turn_start = messages
|
||||
.iter()
|
||||
.rposition(|message| message.role == Role::User && message.origin == Origin::User)
|
||||
.map_or(0, |position| position + 1);
|
||||
messages[turn_start..]
|
||||
.iter()
|
||||
.map(|message| match &message.content {
|
||||
MessageContent::Assistant {
|
||||
text,
|
||||
thinking,
|
||||
tool_calls,
|
||||
..
|
||||
} => {
|
||||
usize::from(!thinking.is_empty()) + usize::from(!text.is_empty()) + tool_calls.len()
|
||||
}
|
||||
_ => 0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
|
||||
fn normalized(name: &str) -> String {
|
||||
name.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
use crate::model::{CanonicalMessage, Origin, Role};
|
||||
|
||||
fn call(name: &str) -> ToolCall {
|
||||
ToolCall {
|
||||
index: 0,
|
||||
call_id: "call-1".into(),
|
||||
model_call_id: "model-1".into(),
|
||||
name: name.into(),
|
||||
arguments_text: "{}".into(),
|
||||
arguments: json!({}),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn communicate_update_completes_locally_at_the_cursor_step_index() {
|
||||
let dispatcher = ToolDispatcher::new(
|
||||
PendingExecRegistry::default(),
|
||||
PendingClientTools::default(),
|
||||
);
|
||||
let calls = [ToolCall {
|
||||
arguments: json!({"current_step": "Reading"}),
|
||||
..call("CommunicateUpdate")
|
||||
}];
|
||||
let user = CanonicalMessage::text("user", Role::User, Origin::User, "go");
|
||||
let dispatched = dispatcher
|
||||
.start_batch(
|
||||
&calls,
|
||||
&HashSet::new(),
|
||||
&[user],
|
||||
"I will inspect it.",
|
||||
"Need to read.",
|
||||
&BTreeMap::new(),
|
||||
&ExecContext::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let completion = dispatched[0].completion.as_ref().unwrap();
|
||||
let Some(pb::tool_call::Tool::CommunicateUpdateToolCall(tool)) =
|
||||
completion.tool_call().tool.as_ref()
|
||||
else {
|
||||
panic!("expected CommunicateUpdateToolCall")
|
||||
};
|
||||
let Some(pb::communicate_update_result::Result::Success(success)) = tool
|
||||
.result
|
||||
.as_ref()
|
||||
.and_then(|result| result.result.as_ref())
|
||||
else {
|
||||
panic!("expected communicate update success")
|
||||
};
|
||||
assert_eq!(success.message_index, 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn approved_web_fetch_moves_from_interaction_to_exec() {
|
||||
let dispatcher = ToolDispatcher::new(
|
||||
PendingExecRegistry::default(),
|
||||
PendingClientTools::default(),
|
||||
);
|
||||
let calls = [ToolCall {
|
||||
arguments: json!({"url": "https://example.com"}),
|
||||
..call("WebFetch")
|
||||
}];
|
||||
let dispatched = dispatcher
|
||||
.start_batch(
|
||||
&calls,
|
||||
&HashSet::new(),
|
||||
&[],
|
||||
"",
|
||||
"",
|
||||
&BTreeMap::new(),
|
||||
&ExecContext::default(),
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let Some(pb::agent_server_message::Message::InteractionQuery(query)) =
|
||||
dispatched[0].messages[1].message.as_ref()
|
||||
else {
|
||||
panic!("expected WebFetch InteractionQuery")
|
||||
};
|
||||
let event = dispatcher
|
||||
.interaction_response(&pb::InteractionResponse {
|
||||
id: query.id,
|
||||
result: Some(pb::interaction_response::Result::WebFetchRequestResponse(
|
||||
pb::WebFetchRequestResponse {
|
||||
result: Some(pb::web_fetch_request_response::Result::Approved(
|
||||
pb::web_fetch_request_response::Approved {},
|
||||
)),
|
||||
},
|
||||
)),
|
||||
})
|
||||
.await
|
||||
.unwrap();
|
||||
let ClientToolEvent::Message(message) = event else {
|
||||
panic!("approval must open the Exec phase")
|
||||
};
|
||||
let Some(pb::agent_server_message::Message::ExecServerMessage(exec)) = message.message
|
||||
else {
|
||||
panic!("expected FetchArgs")
|
||||
};
|
||||
assert!(matches!(
|
||||
exec.message,
|
||||
Some(pb::exec_server_message::Message::FetchArgs(_))
|
||||
));
|
||||
let event = exec::client_event(
|
||||
&pb::ExecClientMessage {
|
||||
id: exec.id,
|
||||
message: Some(pb::exec_client_message::Message::FetchResult(
|
||||
pb::FetchResult {
|
||||
result: Some(pb::fetch_result::Result::Success(pb::FetchSuccess {
|
||||
url: "https://example.com".into(),
|
||||
content: "hello".into(),
|
||||
status_code: 200,
|
||||
content_type: "text/html".into(),
|
||||
})),
|
||||
},
|
||||
)),
|
||||
..Default::default()
|
||||
},
|
||||
&dispatcher.pending_execs,
|
||||
)
|
||||
.await
|
||||
.unwrap();
|
||||
let exec::ClientExecEvent::Completed(completion) = event else {
|
||||
panic!("FetchResult must complete WebFetch")
|
||||
};
|
||||
assert_eq!(completion.result().output, json!("hello"));
|
||||
assert!(matches!(
|
||||
completion.tool_call().tool,
|
||||
Some(pb::tool_call::Tool::WebFetchToolCall(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
mod request;
|
||||
mod response;
|
||||
|
||||
pub use request::{abort, mcp_request, mcp_state_request, request};
|
||||
pub(crate) use request::{
|
||||
await_read_request, edit_read_request, json_object_to_prost, mcp_meta_request,
|
||||
};
|
||||
pub use response::{client_event, ClientExecEvent};
|
||||
@@ -0,0 +1,520 @@
|
||||
use serde_json::{Map, Value};
|
||||
|
||||
use crate::{
|
||||
cursor::{
|
||||
proto::agent::v1 as pb,
|
||||
tools::{
|
||||
edit::{self, EditWrite},
|
||||
runtime::{ExecContext, SubagentModel},
|
||||
},
|
||||
},
|
||||
model::ToolCall,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::AgentServerMessage> {
|
||||
use pb::exec_server_message::Message;
|
||||
let string = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
|
||||
};
|
||||
let optional_string = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
};
|
||||
let int = |name: &str| {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(Value::as_i64)
|
||||
.map(|v| v as i32)
|
||||
};
|
||||
let message = match normalize(&call.name).as_str() {
|
||||
"shell" => Message::ShellStreamArgs(pb::ShellArgs {
|
||||
command: string("command")?,
|
||||
working_directory: optional_string("working_directory").unwrap_or_default(),
|
||||
timeout: shell_timeout(call)?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
file_output_threshold_bytes: Some(40_000),
|
||||
timeout_behavior: pb::TimeoutBehavior::Background as i32,
|
||||
hard_timeout: Some(86_400_000),
|
||||
description: optional_string("description"),
|
||||
output_notification: shell_notification(call)?,
|
||||
smart_mode_approval: smart_mode_approval(
|
||||
call,
|
||||
"request_smart_mode_approval",
|
||||
"smart_mode_block_reason",
|
||||
)?,
|
||||
close_stdin: true,
|
||||
conversation_id: Some(context.conversation_id.clone()),
|
||||
admin_command_denylist: context.admin_command_denylist.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
"read" => Message::ReadArgs(pb::ReadArgs {
|
||||
path: string("path")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
offset: int("offset"),
|
||||
limit: call
|
||||
.arguments
|
||||
.get("limit")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|v| v as u32),
|
||||
encoding_hint: optional_string("encoding_hint"),
|
||||
}),
|
||||
"delete" => Message::DeleteArgs(pb::DeleteArgs {
|
||||
path: string("path")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
"grep" => Message::GrepArgs(pb::GrepArgs {
|
||||
pattern: string("pattern")?,
|
||||
path: optional_string("path"),
|
||||
glob: optional_string("glob"),
|
||||
output_mode: optional_string("output_mode"),
|
||||
context_before: int("-B"),
|
||||
context_after: int("-A"),
|
||||
context: int("-C"),
|
||||
case_insensitive: call.arguments.get("-i").and_then(Value::as_bool),
|
||||
r#type: optional_string("type"),
|
||||
head_limit: int("head_limit"),
|
||||
multiline: call.arguments.get("multiline").and_then(Value::as_bool),
|
||||
sort: optional_string("sort"),
|
||||
sort_ascending: call
|
||||
.arguments
|
||||
.get("sort_ascending")
|
||||
.and_then(Value::as_bool),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
sandbox_policy: None,
|
||||
offset: int("offset"),
|
||||
}),
|
||||
"glob" => Message::GrepArgs(pb::GrepArgs {
|
||||
pattern: String::new(),
|
||||
path: optional_string("target_directory"),
|
||||
glob: optional_string("glob_pattern"),
|
||||
output_mode: Some("files_with_matches".into()),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
"readlints" => Message::DiagnosticsArgs(pb::DiagnosticsArgs {
|
||||
path: call
|
||||
.arguments
|
||||
.get("paths")
|
||||
.and_then(Value::as_array)
|
||||
.and_then(|paths| paths.first())
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
"task" => Message::SubagentArgs(pb::SubagentArgs {
|
||||
tool_call_id: call.call_id.clone(),
|
||||
subagent_type: optional_string("subagent_type").unwrap_or_default(),
|
||||
model_id: task_model(call, context)?,
|
||||
prompt: string("prompt")?,
|
||||
readonly: false,
|
||||
resume_agent_id: optional_string("resume"),
|
||||
run_in_background: call
|
||||
.arguments
|
||||
.get("run_in_background")
|
||||
.and_then(Value::as_bool),
|
||||
continuation_config: None,
|
||||
parent_conversation_id: Some(context.conversation_id.clone()),
|
||||
interrupt: call.arguments.get("interrupt").and_then(Value::as_bool),
|
||||
mode: 0,
|
||||
fork_agent_id: None,
|
||||
root_parent_conversation_id: Some(context.root_conversation_id.clone()),
|
||||
selected_context: task_attachments(call),
|
||||
direct_meta_parent_child_subagent: None,
|
||||
environment: match optional_string("environment").as_deref() {
|
||||
Some("cloud") => pb::SubagentExecutionEnvironment::Cloud as i32,
|
||||
Some("local") | None => pb::SubagentExecutionEnvironment::Local as i32,
|
||||
Some(value) => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"unknown Task environment: {value}"
|
||||
)))
|
||||
}
|
||||
},
|
||||
cloud_base_branch: optional_string("cloud_base_branch"),
|
||||
credentials: None,
|
||||
}),
|
||||
"fetchmcpresource" => Message::ReadMcpResourceExecArgs(pb::ReadMcpResourceExecArgs {
|
||||
server: string("server")?,
|
||||
uri: string("uri")?,
|
||||
download_path: optional_string("downloadPath"),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
smart_mode_approval: smart_mode_approval(
|
||||
call,
|
||||
"requestSmartModeApproval",
|
||||
"smartModeBlockReason",
|
||||
)?,
|
||||
}),
|
||||
"webfetch" => Message::FetchArgs(pb::FetchArgs {
|
||||
url: string("url")?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
}),
|
||||
other => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"tool {other} is not executed through ExecServerMessage"
|
||||
)))
|
||||
}
|
||||
};
|
||||
let accept_hook_additional_contexts =
|
||||
if matches!(&message, pb::exec_server_message::Message::SubagentArgs(_)) {
|
||||
Some(false)
|
||||
} else {
|
||||
Some(true)
|
||||
};
|
||||
Ok(server_message(
|
||||
id,
|
||||
call,
|
||||
message,
|
||||
accept_hook_additional_contexts,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn edit_read_request(id: u32, call: &ToolCall) -> Result<pb::AgentServerMessage> {
|
||||
Ok(server_message(
|
||||
id,
|
||||
call,
|
||||
pb::exec_server_message::Message::ReadArgs(pb::ReadArgs {
|
||||
path: edit::path(call)?,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
Some(true),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn await_read_request(
|
||||
id: u32,
|
||||
call: &ToolCall,
|
||||
context: &ExecContext,
|
||||
) -> Result<pb::AgentServerMessage> {
|
||||
let task_id = call
|
||||
.arguments
|
||||
.get("shell_id")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol("AwaitShell is missing shell_id".into()))?;
|
||||
Ok(server_message(
|
||||
id,
|
||||
call,
|
||||
pb::exec_server_message::Message::ReadArgs(pb::ReadArgs {
|
||||
path: format!(
|
||||
"{}/{}.txt",
|
||||
context.terminals_folder.trim_end_matches('/'),
|
||||
task_id
|
||||
),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
..Default::default()
|
||||
}),
|
||||
Some(false),
|
||||
))
|
||||
}
|
||||
|
||||
pub(super) fn edit_write_request(
|
||||
id: u32,
|
||||
call: &ToolCall,
|
||||
write: &EditWrite,
|
||||
) -> Result<pb::AgentServerMessage> {
|
||||
Ok(server_message(
|
||||
id,
|
||||
call,
|
||||
pb::exec_server_message::Message::WriteArgs(pb::WriteArgs {
|
||||
path: edit::path(call)?,
|
||||
file_text: write.after.clone(),
|
||||
tool_call_id: call.call_id.clone(),
|
||||
return_file_content_after_write: false,
|
||||
file_bytes: Vec::new(),
|
||||
encoding_hint: None,
|
||||
}),
|
||||
Some(true),
|
||||
))
|
||||
}
|
||||
|
||||
fn server_message(
|
||||
id: u32,
|
||||
call: &ToolCall,
|
||||
message: pb::exec_server_message::Message,
|
||||
accept_hook_additional_contexts: Option<bool>,
|
||||
) -> pb::AgentServerMessage {
|
||||
pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::ExecServerMessage(
|
||||
pb::ExecServerMessage {
|
||||
id,
|
||||
exec_id: call.call_id.clone(),
|
||||
span_context: None,
|
||||
accept_hook_additional_contexts,
|
||||
message: Some(message),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn mcp_request(
|
||||
id: u32,
|
||||
call: &ToolCall,
|
||||
definition: &pb::McpToolDefinition,
|
||||
) -> Result<pb::AgentServerMessage> {
|
||||
let args = call
|
||||
.arguments
|
||||
.as_object()
|
||||
.map(json_object_to_prost)
|
||||
.unwrap_or_default();
|
||||
Ok(pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::ExecServerMessage(
|
||||
pb::ExecServerMessage {
|
||||
id,
|
||||
exec_id: call.call_id.clone(),
|
||||
span_context: None,
|
||||
accept_hook_additional_contexts: None,
|
||||
message: Some(pb::exec_server_message::Message::McpArgs(pb::McpArgs {
|
||||
name: definition.name.clone(),
|
||||
args,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
provider_identifier: definition.provider_identifier.clone(),
|
||||
tool_name: definition.tool_name.clone(),
|
||||
smart_mode_approval: None,
|
||||
smart_mode_approval_only: false,
|
||||
skip_approval: false,
|
||||
server_identifier: String::new(),
|
||||
})),
|
||||
},
|
||||
)),
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn mcp_meta_request(
|
||||
id: u32,
|
||||
call: &ToolCall,
|
||||
server_identifier: &str,
|
||||
definition: &pb::McpToolDefinition,
|
||||
) -> Result<pb::AgentServerMessage> {
|
||||
if definition.name.is_empty()
|
||||
|| definition.provider_identifier.is_empty()
|
||||
|| definition.tool_name.is_empty()
|
||||
{
|
||||
return Err(Error::Protocol(format!(
|
||||
"MCP definition for {server_identifier} is incomplete"
|
||||
)));
|
||||
}
|
||||
let requested_tool = call
|
||||
.arguments
|
||||
.get("toolName")
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol("CallMcpTool is missing toolName".into()))?;
|
||||
if requested_tool != definition.tool_name {
|
||||
return Err(Error::Protocol(format!(
|
||||
"MCP definition mismatch: requested {requested_tool}, resolved {}",
|
||||
definition.tool_name
|
||||
)));
|
||||
}
|
||||
let args = call
|
||||
.arguments
|
||||
.get("arguments")
|
||||
.and_then(Value::as_object)
|
||||
.map(json_object_to_prost)
|
||||
.unwrap_or_default();
|
||||
Ok(server_message(
|
||||
id,
|
||||
call,
|
||||
pb::exec_server_message::Message::McpArgs(pb::McpArgs {
|
||||
name: definition.name.clone(),
|
||||
args,
|
||||
tool_call_id: call.call_id.clone(),
|
||||
provider_identifier: definition.provider_identifier.clone(),
|
||||
tool_name: definition.tool_name.clone(),
|
||||
smart_mode_approval: smart_mode_approval(
|
||||
call,
|
||||
"requestSmartModeApproval",
|
||||
"smartModeBlockReason",
|
||||
)?,
|
||||
smart_mode_approval_only: false,
|
||||
skip_approval: false,
|
||||
server_identifier: server_identifier.into(),
|
||||
}),
|
||||
Some(true),
|
||||
))
|
||||
}
|
||||
|
||||
pub fn mcp_state_request(id: u32, call: &ToolCall) -> pb::AgentServerMessage {
|
||||
let server_identifiers = call
|
||||
.arguments
|
||||
.get("server")
|
||||
.and_then(Value::as_str)
|
||||
.map(|server| vec![server.into()])
|
||||
.unwrap_or_default();
|
||||
server_message(
|
||||
id,
|
||||
call,
|
||||
pb::exec_server_message::Message::McpStateExecArgs(pb::McpStateExecArgs {
|
||||
server_identifiers,
|
||||
kick_only: false,
|
||||
}),
|
||||
Some(false),
|
||||
)
|
||||
}
|
||||
|
||||
pub fn abort(id: u32) -> pb::AgentServerMessage {
|
||||
pb::AgentServerMessage {
|
||||
ttft_breakdown: None,
|
||||
message: Some(pb::agent_server_message::Message::ExecServerControlMessage(
|
||||
pb::ExecServerControlMessage {
|
||||
message: Some(pb::exec_server_control_message::Message::Abort(
|
||||
pb::ExecServerAbort { id },
|
||||
)),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_timeout(call: &ToolCall) -> Result<i32> {
|
||||
let value = call
|
||||
.arguments
|
||||
.get("block_until_ms")
|
||||
.map(|value| {
|
||||
value
|
||||
.as_i64()
|
||||
.ok_or_else(|| Error::Protocol("Shell block_until_ms must be an integer".into()))
|
||||
})
|
||||
.transpose()?
|
||||
.unwrap_or(30_000);
|
||||
i32::try_from(value)
|
||||
.ok()
|
||||
.filter(|value| *value >= 0)
|
||||
.ok_or_else(|| Error::Protocol("Shell block_until_ms is out of range".into()))
|
||||
}
|
||||
|
||||
fn smart_mode_approval(
|
||||
call: &ToolCall,
|
||||
request_field: &str,
|
||||
reason_field: &str,
|
||||
) -> Result<Option<pb::SmartModeApproval>> {
|
||||
if !call
|
||||
.arguments
|
||||
.get(request_field)
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let reason = call
|
||||
.arguments
|
||||
.get(reason_field)
|
||||
.and_then(Value::as_str)
|
||||
.ok_or_else(|| Error::Protocol(format!("{} requires {reason_field}", call.name)))?;
|
||||
Ok(Some(pb::SmartModeApproval {
|
||||
request_id: call.call_id.clone(),
|
||||
reason: reason.to_string(),
|
||||
}))
|
||||
}
|
||||
|
||||
fn shell_notification(call: &ToolCall) -> Result<Option<pb::ShellOutputNotificationConfig>> {
|
||||
let Some(value) = call.arguments.get("notify_on_output") else {
|
||||
return Ok(None);
|
||||
};
|
||||
let object = value
|
||||
.as_object()
|
||||
.ok_or_else(|| Error::Protocol("Shell notify_on_output must be an object".into()))?;
|
||||
let required = |field: &str| {
|
||||
object
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_string)
|
||||
.ok_or_else(|| Error::Protocol(format!("Shell notify_on_output is missing {field}")))
|
||||
};
|
||||
Ok(Some(pb::ShellOutputNotificationConfig {
|
||||
pattern: required("pattern")?,
|
||||
reason: required("reason")?,
|
||||
debounce: object.get("debounce_ms").and_then(Value::as_f64),
|
||||
notification_limit: None,
|
||||
}))
|
||||
}
|
||||
|
||||
fn task_attachments(call: &ToolCall) -> Option<pb::SelectedContext> {
|
||||
let paths = call.arguments.get("file_attachments")?.as_array()?;
|
||||
let mut context = pb::SelectedContext::default();
|
||||
for path in paths.iter().filter_map(Value::as_str) {
|
||||
let extension = std::path::Path::new(path)
|
||||
.extension()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.unwrap_or_default()
|
||||
.to_ascii_lowercase();
|
||||
if matches!(extension.as_str(), "mp4" | "mov" | "webm" | "mkv") {
|
||||
context.selected_videos.push(pb::SelectedVideo {
|
||||
path: path.into(),
|
||||
filename: std::path::Path::new(path)
|
||||
.file_name()
|
||||
.and_then(std::ffi::OsStr::to_str)
|
||||
.unwrap_or_default()
|
||||
.into(),
|
||||
materialize_to_filesystem: true,
|
||||
..Default::default()
|
||||
});
|
||||
} else {
|
||||
context.selected_images.push(pb::SelectedImage {
|
||||
path: path.into(),
|
||||
..Default::default()
|
||||
});
|
||||
}
|
||||
}
|
||||
Some(context)
|
||||
}
|
||||
|
||||
fn task_model(call: &ToolCall, context: &ExecContext) -> Result<String> {
|
||||
let subagent_type = call
|
||||
.arguments
|
||||
.get("subagent_type")
|
||||
.and_then(Value::as_str)
|
||||
.unwrap_or("generalPurpose");
|
||||
if let Some(selection) = context.subagent_models.get(subagent_type) {
|
||||
return match selection {
|
||||
SubagentModel::Model(model) => Ok(model.clone()),
|
||||
SubagentModel::Disabled => Err(Error::Protocol(format!(
|
||||
"Task subagent type {subagent_type} is disabled"
|
||||
))),
|
||||
};
|
||||
}
|
||||
match call.arguments.get("model").and_then(Value::as_str) {
|
||||
None | Some("inherit") => Ok(context.model_id.clone()),
|
||||
Some(model) => Ok(model.into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn normalize(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|c| c.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn json_object_to_prost(
|
||||
value: &Map<String, Value>,
|
||||
) -> std::collections::HashMap<String, prost_types::Value> {
|
||||
value
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), prost_value(value)))
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn prost_value(value: &Value) -> prost_types::Value {
|
||||
use prost_types::{value::Kind, ListValue, Struct, Value as ProstValue};
|
||||
let kind = match value {
|
||||
Value::Null => Kind::NullValue(0),
|
||||
Value::Bool(v) => Kind::BoolValue(*v),
|
||||
Value::Number(v) => Kind::NumberValue(v.as_f64().unwrap_or_default()),
|
||||
Value::String(v) => Kind::StringValue(v.clone()),
|
||||
Value::Array(v) => Kind::ListValue(ListValue {
|
||||
values: v.iter().map(prost_value).collect(),
|
||||
}),
|
||||
Value::Object(v) => Kind::StructValue(Struct {
|
||||
fields: json_object_to_prost(v).into_iter().collect(),
|
||||
}),
|
||||
};
|
||||
ProstValue { kind: Some(kind) }
|
||||
}
|
||||
@@ -0,0 +1,360 @@
|
||||
use crate::{
|
||||
cursor::{
|
||||
interaction,
|
||||
proto::agent::v1 as pb,
|
||||
tools::{
|
||||
edit,
|
||||
result::{self, ToolCompletion},
|
||||
runtime::{CursorToolRuntime, ExecStage, PendingExec},
|
||||
},
|
||||
},
|
||||
model::ToolCall,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::request::{await_read_request, edit_write_request};
|
||||
|
||||
pub enum ClientExecEvent {
|
||||
Delta(Box<pb::AgentServerMessage>),
|
||||
Message(Box<pb::AgentServerMessage>),
|
||||
Completed(Box<ToolCompletion>),
|
||||
Pending,
|
||||
}
|
||||
|
||||
pub async fn client_event(
|
||||
message: &pb::ExecClientMessage,
|
||||
pending: &CursorToolRuntime,
|
||||
) -> Result<ClientExecEvent> {
|
||||
let call = match pending.exec_call(message.id).await {
|
||||
Some(call) => call,
|
||||
None if pending.completed_call(message.id).await.is_some() => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"duplicate terminal ExecClientMessage id: {}",
|
||||
message.id
|
||||
)))
|
||||
}
|
||||
None => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"unknown ExecClientMessage id: {}",
|
||||
message.id
|
||||
)))
|
||||
}
|
||||
};
|
||||
let Some(wire_result) = &message.message else {
|
||||
return Ok(ClientExecEvent::Pending);
|
||||
};
|
||||
let pb::exec_client_message::Message::ShellStream(stream) = wire_result else {
|
||||
let entry = take(message.id, pending).await?;
|
||||
return match entry.stage {
|
||||
ExecStage::EditRead => advance_edit(entry, wire_result, pending).await,
|
||||
ExecStage::Await(_) => advance_await(entry, wire_result, pending).await,
|
||||
ExecStage::Direct | ExecStage::EditWrite(_) => {
|
||||
if let pb::exec_client_message::Message::McpStateExecResult(state) = wire_result {
|
||||
pending.remember_mcp_state(&entry.call, state).await;
|
||||
}
|
||||
completed(entry, wire_result.clone())
|
||||
}
|
||||
};
|
||||
};
|
||||
use pb::shell_stream::Event;
|
||||
let event = match &stream.event {
|
||||
Some(Event::Stdout(stdout)) => {
|
||||
if pending.append_stdout(message.id, &stdout.data).await {
|
||||
ClientExecEvent::Delta(Box::new(shell_delta(&call, true, &stdout.data)))
|
||||
} else {
|
||||
ClientExecEvent::Pending
|
||||
}
|
||||
}
|
||||
Some(Event::Stderr(stderr)) => {
|
||||
if pending.append_stderr(message.id, &stderr.data).await {
|
||||
ClientExecEvent::Delta(Box::new(shell_delta(&call, false, &stderr.data)))
|
||||
} else {
|
||||
ClientExecEvent::Pending
|
||||
}
|
||||
}
|
||||
Some(Event::Start(_)) | Some(Event::HookContext(_)) => ClientExecEvent::Pending,
|
||||
Some(Event::Exit(exit)) => {
|
||||
let entry = take(message.id, pending).await?;
|
||||
let result = shell_exit_result(message, exit, &entry.stdout, &entry.stderr);
|
||||
completed(entry, pb::exec_client_message::Message::ShellResult(result))?
|
||||
}
|
||||
Some(Event::Backgrounded(backgrounded)) => {
|
||||
let entry = take(message.id, pending).await?;
|
||||
let result = shell_backgrounded_result(
|
||||
backgrounded,
|
||||
&entry.stdout,
|
||||
&entry.stderr,
|
||||
&entry.context.terminals_folder,
|
||||
);
|
||||
completed(entry, pb::exec_client_message::Message::ShellResult(result))?
|
||||
}
|
||||
Some(Event::Rejected(value)) => {
|
||||
let result = pb::ShellResult {
|
||||
result: Some(pb::shell_result::Result::Rejected(value.clone())),
|
||||
..Default::default()
|
||||
};
|
||||
complete(
|
||||
message.id,
|
||||
pending,
|
||||
pb::exec_client_message::Message::ShellResult(result),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Some(Event::PermissionDenied(value)) => {
|
||||
let result = pb::ShellResult {
|
||||
result: Some(pb::shell_result::Result::PermissionDenied(value.clone())),
|
||||
..Default::default()
|
||||
};
|
||||
complete(
|
||||
message.id,
|
||||
pending,
|
||||
pb::exec_client_message::Message::ShellResult(result),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
Some(Event::SandboxUnsupported(value)) => {
|
||||
let result = pb::ShellResult {
|
||||
result: Some(pb::shell_result::Result::SpawnError(pb::ShellSpawnError {
|
||||
command: value.command.clone(),
|
||||
working_directory: value.working_directory.clone(),
|
||||
error: value.reason.clone(),
|
||||
})),
|
||||
..Default::default()
|
||||
};
|
||||
complete(
|
||||
message.id,
|
||||
pending,
|
||||
pb::exec_client_message::Message::ShellResult(result),
|
||||
)
|
||||
.await?
|
||||
}
|
||||
None => ClientExecEvent::Pending,
|
||||
};
|
||||
Ok(event)
|
||||
}
|
||||
|
||||
async fn advance_await(
|
||||
entry: PendingExec,
|
||||
result: &pb::exec_client_message::Message,
|
||||
registry: &CursorToolRuntime,
|
||||
) -> Result<ClientExecEvent> {
|
||||
let read = match result {
|
||||
pb::exec_client_message::Message::ReadResult(result)
|
||||
| pb::exec_client_message::Message::RedactedReadResult(result) => result,
|
||||
_ => return Err(Error::Protocol("AwaitShell expected ReadResult".into())),
|
||||
};
|
||||
let ExecStage::Await(state) = &entry.stage else {
|
||||
return Err(Error::Protocol(
|
||||
"AwaitShell result reached a non-await execution stage".into(),
|
||||
));
|
||||
};
|
||||
let content = match read.result.as_ref() {
|
||||
Some(pb::read_result::Result::Success(success)) => match success.output.as_ref() {
|
||||
Some(pb::read_success::Output::Content(content)) => content.as_str(),
|
||||
_ => "",
|
||||
},
|
||||
Some(pb::read_result::Result::FileNotFound(_)) => "",
|
||||
Some(pb::read_result::Result::Error(error)) => {
|
||||
return Ok(ClientExecEvent::Completed(Box::new(result::await_error(
|
||||
entry,
|
||||
&error.error,
|
||||
)?)))
|
||||
}
|
||||
_ => "",
|
||||
};
|
||||
let regex_match = state
|
||||
.regex
|
||||
.as_ref()
|
||||
.map(|pattern| regex::Regex::new(pattern))
|
||||
.transpose()
|
||||
.map_err(|error| Error::Protocol(format!("invalid AwaitShell pattern: {error}")))?
|
||||
.and_then(|pattern| {
|
||||
pattern
|
||||
.find(content)
|
||||
.map(|found| found.as_str().to_string())
|
||||
});
|
||||
let exit_code = content.lines().find_map(|line| {
|
||||
line.strip_prefix("exit_code:")
|
||||
.and_then(|value| value.trim().parse::<i32>().ok())
|
||||
});
|
||||
if regex_match.is_some() || exit_code.is_some() || std::time::Instant::now() >= state.deadline {
|
||||
return Ok(ClientExecEvent::Completed(Box::new(result::await_result(
|
||||
entry,
|
||||
content.len() as u64,
|
||||
regex_match,
|
||||
exit_code,
|
||||
)?)));
|
||||
}
|
||||
let state = match entry.stage {
|
||||
ExecStage::Await(state) => state,
|
||||
_ => {
|
||||
return Err(Error::Protocol(
|
||||
"AwaitShell result changed execution stage".into(),
|
||||
))
|
||||
}
|
||||
};
|
||||
let wait = state
|
||||
.deadline
|
||||
.saturating_duration_since(std::time::Instant::now())
|
||||
.min(std::time::Duration::from_secs(1));
|
||||
tokio::time::sleep(wait).await;
|
||||
let call = entry.call.clone();
|
||||
let context = entry.context.clone();
|
||||
let id = registry
|
||||
.reserve_await_again(&call, &context, state, entry.started_at_ms)
|
||||
.await?;
|
||||
Ok(ClientExecEvent::Message(Box::new(await_read_request(
|
||||
id, &call, &context,
|
||||
)?)))
|
||||
}
|
||||
|
||||
async fn advance_edit(
|
||||
entry: PendingExec,
|
||||
result: &pb::exec_client_message::Message,
|
||||
registry: &CursorToolRuntime,
|
||||
) -> Result<ClientExecEvent> {
|
||||
let read = match result {
|
||||
pb::exec_client_message::Message::ReadResult(result)
|
||||
| pb::exec_client_message::Message::RedactedReadResult(result) => result,
|
||||
_ => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"expected ReadResult for edit tool {}",
|
||||
entry.call.name
|
||||
)))
|
||||
}
|
||||
};
|
||||
let write = match edit::after_read(&entry.call, read) {
|
||||
Ok(write) => write,
|
||||
Err(error) => {
|
||||
return Ok(ClientExecEvent::Completed(Box::new(result::edit_failure(
|
||||
entry, error,
|
||||
)?)))
|
||||
}
|
||||
};
|
||||
let id = registry
|
||||
.reserve_edit_write(
|
||||
&entry.call,
|
||||
&entry.context,
|
||||
write.clone(),
|
||||
entry.started_at_ms,
|
||||
)
|
||||
.await?;
|
||||
Ok(ClientExecEvent::Message(Box::new(edit_write_request(
|
||||
id,
|
||||
&entry.call,
|
||||
&write,
|
||||
)?)))
|
||||
}
|
||||
|
||||
async fn complete(
|
||||
id: u32,
|
||||
pending: &CursorToolRuntime,
|
||||
result: pb::exec_client_message::Message,
|
||||
) -> Result<ClientExecEvent> {
|
||||
completed(take(id, pending).await?, result)
|
||||
}
|
||||
|
||||
async fn take(id: u32, pending: &CursorToolRuntime) -> Result<PendingExec> {
|
||||
pending
|
||||
.take_exec(id)
|
||||
.await
|
||||
.ok_or_else(|| Error::Protocol(format!("unknown terminal Exec id: {id}")))
|
||||
}
|
||||
|
||||
fn completed(
|
||||
pending: PendingExec,
|
||||
result: pb::exec_client_message::Message,
|
||||
) -> Result<ClientExecEvent> {
|
||||
Ok(ClientExecEvent::Completed(Box::new(result::from_exec(
|
||||
pending, &result,
|
||||
)?)))
|
||||
}
|
||||
|
||||
fn shell_exit_result(
|
||||
message: &pb::ExecClientMessage,
|
||||
exit: &pb::ShellStreamExit,
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
) -> pb::ShellResult {
|
||||
let result = if exit.code == 0 && !exit.aborted {
|
||||
pb::shell_result::Result::Success(pb::ShellSuccess {
|
||||
working_directory: exit.cwd.clone(),
|
||||
exit_code: exit.code as i32,
|
||||
stdout: stdout.into(),
|
||||
stderr: stderr.into(),
|
||||
interleaved_output: Some(format!("{stdout}{stderr}")),
|
||||
local_execution_time_ms: exit
|
||||
.local_execution_time_ms
|
||||
.or(message.local_execution_time_ms),
|
||||
..Default::default()
|
||||
})
|
||||
} else {
|
||||
pb::shell_result::Result::Failure(pb::ShellFailure {
|
||||
working_directory: exit.cwd.clone(),
|
||||
exit_code: exit.code as i32,
|
||||
stdout: stdout.into(),
|
||||
stderr: stderr.into(),
|
||||
interleaved_output: Some(format!("{stdout}{stderr}")),
|
||||
abort_reason: exit.abort_reason,
|
||||
aborted: exit.aborted,
|
||||
local_execution_time_ms: exit
|
||||
.local_execution_time_ms
|
||||
.or(message.local_execution_time_ms),
|
||||
..Default::default()
|
||||
})
|
||||
};
|
||||
pb::ShellResult {
|
||||
result: Some(result),
|
||||
is_background: Some(false),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_backgrounded_result(
|
||||
backgrounded: &pb::ShellStreamBackgrounded,
|
||||
stdout: &str,
|
||||
stderr: &str,
|
||||
terminals_folder: &str,
|
||||
) -> pb::ShellResult {
|
||||
pb::ShellResult {
|
||||
result: Some(pb::shell_result::Result::Success(pb::ShellSuccess {
|
||||
command: backgrounded.command.clone(),
|
||||
working_directory: backgrounded.working_directory.clone(),
|
||||
stdout: stdout.into(),
|
||||
stderr: stderr.into(),
|
||||
shell_id: Some(backgrounded.shell_id),
|
||||
pid: backgrounded.pid,
|
||||
ms_to_wait: backgrounded.ms_to_wait,
|
||||
background_reason: backgrounded.reason,
|
||||
interleaved_output: Some(format!("{stdout}{stderr}")),
|
||||
..Default::default()
|
||||
})),
|
||||
is_background: Some(true),
|
||||
terminals_folder: (!terminals_folder.is_empty()).then(|| terminals_folder.into()),
|
||||
pid: backgrounded.pid,
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
fn shell_delta(call: &ToolCall, stdout: bool, content: &str) -> pb::AgentServerMessage {
|
||||
let delta = if stdout {
|
||||
pb::shell_tool_call_delta::Delta::Stdout(pb::ShellToolCallStdoutDelta {
|
||||
content: content.into(),
|
||||
})
|
||||
} else {
|
||||
pb::shell_tool_call_delta::Delta::Stderr(pb::ShellToolCallStderrDelta {
|
||||
content: content.into(),
|
||||
})
|
||||
};
|
||||
interaction::server_interaction(pb::interaction_update::Message::ToolCallDelta(Box::new(
|
||||
pb::ToolCallDeltaUpdate {
|
||||
call_id: call.call_id.clone(),
|
||||
tool_call_delta: Some(Box::new(pb::ToolCallDelta {
|
||||
delta: Some(pb::tool_call_delta::Delta::ShellToolCallDelta(
|
||||
pb::ShellToolCallDelta { delta: Some(delta) },
|
||||
)),
|
||||
})),
|
||||
model_call_id: call.model_call_id.clone(),
|
||||
},
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! AwaitShell's timed and file-backed execution paths.
|
||||
|
||||
use crate::{model::ToolCall, Error, Result};
|
||||
|
||||
use super::ToolStart;
|
||||
use crate::cursor::tools::{
|
||||
codec, result,
|
||||
result::ToolResultSender,
|
||||
runtime::{CursorToolRuntime, ExecContext},
|
||||
};
|
||||
|
||||
pub(super) async fn start(
|
||||
runtime: &CursorToolRuntime,
|
||||
results: &ToolResultSender,
|
||||
call: &ToolCall,
|
||||
context: &ExecContext,
|
||||
) -> Result<ToolStart> {
|
||||
let message = if call
|
||||
.arguments
|
||||
.get("shell_id")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.is_some()
|
||||
{
|
||||
let id = runtime.reserve_await(call, context).await?;
|
||||
Some(codec::await_read_request(id, call, context)?)
|
||||
} else {
|
||||
wait_without_shell_id(results, call)?;
|
||||
None
|
||||
};
|
||||
Ok(ToolStart {
|
||||
messages: message.into_iter().collect(),
|
||||
completion: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn wait_without_shell_id(results: &ToolResultSender, call: &ToolCall) -> Result<()> {
|
||||
let block_ms = call
|
||||
.arguments
|
||||
.get("block_until_ms")
|
||||
.and_then(serde_json::Value::as_u64)
|
||||
.unwrap_or(30_000);
|
||||
if block_ms == 0 || block_ms > 7_140_000 {
|
||||
return Err(Error::Protocol(
|
||||
"AwaitShell without shell_id requires block_until_ms in 1..=7140000".into(),
|
||||
));
|
||||
}
|
||||
let call = call.clone();
|
||||
let results = results.clone();
|
||||
tokio::spawn(async move {
|
||||
tokio::time::sleep(std::time::Duration::from_millis(block_ms)).await;
|
||||
results.send(result::await_sleep(&call, block_ms));
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
//! Hidden read phase for file editing tools.
|
||||
|
||||
use crate::{model::ToolCall, Result};
|
||||
|
||||
use super::ToolStart;
|
||||
use crate::cursor::tools::{
|
||||
codec,
|
||||
runtime::{CursorToolRuntime, ExecContext},
|
||||
};
|
||||
|
||||
pub(super) async fn start(
|
||||
runtime: &CursorToolRuntime,
|
||||
call: &ToolCall,
|
||||
context: &ExecContext,
|
||||
) -> Result<ToolStart> {
|
||||
let id = runtime.reserve_edit_read(call, context).await?;
|
||||
Ok(ToolStart {
|
||||
messages: vec![codec::edit_read_request(id, call)?],
|
||||
completion: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,62 @@
|
||||
//! Direct Exec and dynamic MCP dispatch.
|
||||
|
||||
use crate::{cursor::proto::agent::v1 as pb, model::ToolCall, Error, Result};
|
||||
|
||||
use super::{normalized, ToolStart};
|
||||
use crate::cursor::tools::{
|
||||
codec,
|
||||
runtime::{CursorToolRuntime, ExecContext},
|
||||
};
|
||||
|
||||
pub(super) async fn start(
|
||||
runtime: &CursorToolRuntime,
|
||||
call: &ToolCall,
|
||||
context: &ExecContext,
|
||||
) -> Result<ToolStart> {
|
||||
let message = match normalized(&call.name).as_str() {
|
||||
"getmcptools" => {
|
||||
let id = runtime.reserve_exec(call, context).await?;
|
||||
codec::mcp_state_request(id, call)
|
||||
}
|
||||
"callmcptool" => {
|
||||
let server = required(call, "server")?;
|
||||
let tool = required(call, "toolName")?;
|
||||
let definition = runtime.mcp_tool(server, tool).await.ok_or_else(|| {
|
||||
Error::Protocol(format!(
|
||||
"CallMcpTool has no definition for {server}/{tool}; call GetMcpTools first"
|
||||
))
|
||||
})?;
|
||||
let id = runtime.reserve_exec(call, context).await?;
|
||||
codec::mcp_meta_request(id, call, server, &definition)?
|
||||
}
|
||||
_ => {
|
||||
let id = runtime.reserve_exec(call, context).await?;
|
||||
codec::request(id, call, context)?
|
||||
}
|
||||
};
|
||||
Ok(ToolStart {
|
||||
messages: vec![message],
|
||||
completion: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn required<'a>(call: &'a ToolCall, name: &str) -> Result<&'a str> {
|
||||
call.arguments
|
||||
.get(name)
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|value| !value.is_empty())
|
||||
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
|
||||
}
|
||||
|
||||
pub(super) async fn start_dynamic(
|
||||
runtime: &CursorToolRuntime,
|
||||
call: &ToolCall,
|
||||
definition: &pb::McpToolDefinition,
|
||||
context: &ExecContext,
|
||||
) -> Result<ToolStart> {
|
||||
let id = runtime.reserve_exec(call, context).await?;
|
||||
Ok(ToolStart {
|
||||
messages: vec![codec::mcp_request(id, call, definition)?],
|
||||
completion: None,
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! Interaction query dispatch and approval continuation.
|
||||
|
||||
use crate::{
|
||||
cursor::{interaction, proto::agent::v1 as pb},
|
||||
model::ToolCall,
|
||||
Result,
|
||||
};
|
||||
|
||||
use super::{normalized, InteractionContinuation, ToolStart};
|
||||
use crate::cursor::tools::{
|
||||
codec, result,
|
||||
runtime::{CursorToolRuntime, ExecContext, PendingInteraction},
|
||||
};
|
||||
|
||||
pub(super) async fn start(
|
||||
runtime: &CursorToolRuntime,
|
||||
call: &ToolCall,
|
||||
context: &ExecContext,
|
||||
) -> Result<ToolStart> {
|
||||
let id = runtime.reserve_interaction(call, context).await?;
|
||||
Ok(ToolStart {
|
||||
messages: vec![interaction::tool_query(id, call)?],
|
||||
completion: None,
|
||||
})
|
||||
}
|
||||
|
||||
pub(super) async fn resume(
|
||||
runtime: &CursorToolRuntime,
|
||||
pending: PendingInteraction,
|
||||
response: &pb::InteractionResponse,
|
||||
) -> Result<InteractionContinuation> {
|
||||
if normalized(&pending.call.name) == "webfetch"
|
||||
&& matches!(
|
||||
response.result.as_ref(),
|
||||
Some(pb::interaction_response::Result::WebFetchRequestResponse(
|
||||
pb::WebFetchRequestResponse {
|
||||
result: Some(pb::web_fetch_request_response::Result::Approved(_)),
|
||||
}
|
||||
))
|
||||
)
|
||||
{
|
||||
let id = runtime
|
||||
.reserve_exec(&pending.call, &pending.context)
|
||||
.await?;
|
||||
return Ok(InteractionContinuation::Message(Box::new(codec::request(
|
||||
id,
|
||||
&pending.call,
|
||||
&pending.context,
|
||||
)?)));
|
||||
}
|
||||
Ok(InteractionContinuation::Completed(Box::new(
|
||||
result::from_interaction(pending, response)?,
|
||||
)))
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
//! Synchronous local tool dispatch.
|
||||
|
||||
use crate::{model::ToolCall, Result};
|
||||
|
||||
use super::ToolStart;
|
||||
use crate::cursor::tools::result;
|
||||
|
||||
pub(super) fn start(call: &ToolCall, message_index: usize) -> Result<ToolStart> {
|
||||
Ok(ToolStart {
|
||||
messages: Vec::new(),
|
||||
completion: Some(result::local(call, message_index)?),
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
mod await_shell;
|
||||
mod edit;
|
||||
mod exec;
|
||||
mod interaction;
|
||||
mod local;
|
||||
|
||||
use std::collections::BTreeMap;
|
||||
|
||||
use crate::{cursor::proto::agent::v1 as pb, model::ToolCall, Error, Result};
|
||||
|
||||
use super::{
|
||||
result::{ToolCompletion, ToolResultSender},
|
||||
runtime::{CursorToolRuntime, ExecContext, PendingInteraction},
|
||||
};
|
||||
|
||||
pub(super) struct ToolStart {
|
||||
pub messages: Vec<pb::AgentServerMessage>,
|
||||
pub completion: Option<ToolCompletion>,
|
||||
}
|
||||
|
||||
pub(super) enum InteractionContinuation {
|
||||
Message(Box<pb::AgentServerMessage>),
|
||||
Completed(Box<ToolCompletion>),
|
||||
}
|
||||
|
||||
pub(super) async fn start(
|
||||
runtime: &CursorToolRuntime,
|
||||
results: &ToolResultSender,
|
||||
call: &ToolCall,
|
||||
message_index: usize,
|
||||
dynamic_mcp: &BTreeMap<String, pb::McpToolDefinition>,
|
||||
context: &ExecContext,
|
||||
) -> Result<ToolStart> {
|
||||
if let Some(definition) = dynamic_mcp.get(&call.name) {
|
||||
return exec::start_dynamic(runtime, call, definition, context).await;
|
||||
}
|
||||
|
||||
match normalized(&call.name).as_str() {
|
||||
"shell" | "read" | "delete" | "grep" | "glob" | "readlints" | "task" | "callmcptool"
|
||||
| "fetchmcpresource" | "getmcptools" => exec::start(runtime, call, context).await,
|
||||
"write" | "strreplace" | "editnotebook" => edit::start(runtime, call, context).await,
|
||||
"askquestion" | "websearch" | "webfetch" | "switchmode" | "createplan"
|
||||
| "generateimage" => interaction::start(runtime, call, context).await,
|
||||
"todowrite" | "updatecurrentstep" => local::start(call, message_index),
|
||||
"awaitshell" => await_shell::start(runtime, results, call, context).await,
|
||||
_ => Err(Error::Protocol(format!("unsupported tool: {}", call.name))),
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) async fn resume_interaction(
|
||||
runtime: &CursorToolRuntime,
|
||||
pending: PendingInteraction,
|
||||
response: &pb::InteractionResponse,
|
||||
) -> Result<InteractionContinuation> {
|
||||
interaction::resume(runtime, pending, response).await
|
||||
}
|
||||
|
||||
pub(super) fn normalized(name: &str) -> String {
|
||||
name.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
use serde_json::Value;
|
||||
use similar::{ChangeTag, TextDiff};
|
||||
|
||||
use crate::{model::ToolCall, Error, Result};
|
||||
|
||||
use crate::cursor::proto::agent::v1 as pb;
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
pub(crate) struct EditWrite {
|
||||
pub before: String,
|
||||
pub after: String,
|
||||
}
|
||||
|
||||
pub(crate) fn path(call: &ToolCall) -> Result<String> {
|
||||
let field = if normalized(&call.name) == "editnotebook" {
|
||||
"target_notebook"
|
||||
} else {
|
||||
"path"
|
||||
};
|
||||
string(call, field)
|
||||
}
|
||||
|
||||
pub(crate) fn after_read(
|
||||
call: &ToolCall,
|
||||
result: &pb::ReadResult,
|
||||
) -> std::result::Result<EditWrite, String> {
|
||||
let before = match result.result.as_ref() {
|
||||
Some(pb::read_result::Result::Success(success)) => {
|
||||
if success.truncated {
|
||||
return Err("cannot edit a truncated Read result".into());
|
||||
}
|
||||
match success.output.as_ref() {
|
||||
Some(pb::read_success::Output::Content(content)) => normalize_newlines(content),
|
||||
Some(pb::read_success::Output::Data(_)) => {
|
||||
return Err("cannot edit a binary file".into());
|
||||
}
|
||||
None => return Err("Read result has no file content".into()),
|
||||
}
|
||||
}
|
||||
Some(pb::read_result::Result::FileNotFound(_)) if normalized(&call.name) == "write" => {
|
||||
String::new()
|
||||
}
|
||||
Some(pb::read_result::Result::FileNotFound(_)) => {
|
||||
return Err("file not found".into());
|
||||
}
|
||||
Some(pb::read_result::Result::Error(value)) => return Err(value.error.clone()),
|
||||
Some(pb::read_result::Result::Rejected(value)) => return Err(value.reason.clone()),
|
||||
Some(pb::read_result::Result::PermissionDenied(_)) => {
|
||||
return Err("read permission denied".into());
|
||||
}
|
||||
Some(pb::read_result::Result::InvalidFile(value)) => {
|
||||
return Err(value.reason.clone());
|
||||
}
|
||||
None => return Err("Read result is empty".into()),
|
||||
};
|
||||
let after = match normalized(&call.name).as_str() {
|
||||
"write" => {
|
||||
normalize_newlines(&string(call, "contents").map_err(|error| error.to_string())?)
|
||||
}
|
||||
"strreplace" => replace_string(call, &before)?,
|
||||
"editnotebook" => edit_notebook(call, &before)?,
|
||||
_ => return Err(format!("{} is not an edit tool", call.name)),
|
||||
};
|
||||
Ok(EditWrite { before, after })
|
||||
}
|
||||
|
||||
pub(crate) fn success(path: String, write: &EditWrite) -> pb::EditResult {
|
||||
let diff = TextDiff::from_lines(&write.before, &write.after);
|
||||
let (mut added, mut removed) = (0, 0);
|
||||
for change in diff.iter_all_changes() {
|
||||
match change.tag() {
|
||||
ChangeTag::Delete => removed += 1,
|
||||
ChangeTag::Insert => added += 1,
|
||||
ChangeTag::Equal => {}
|
||||
}
|
||||
}
|
||||
pb::EditResult {
|
||||
result: Some(pb::edit_result::Result::Success(pb::EditSuccess {
|
||||
path,
|
||||
lines_added: Some(added),
|
||||
lines_removed: Some(removed),
|
||||
diff_string: Some(diff.unified_diff().to_string()),
|
||||
before_full_file_content: Some(write.before.clone()),
|
||||
after_full_file_content: write.after.clone(),
|
||||
message: None,
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn failure(path: String, error: impl Into<String>) -> pb::EditResult {
|
||||
let error = error.into();
|
||||
pb::EditResult {
|
||||
result: Some(pb::edit_result::Result::Error(pb::EditError {
|
||||
path,
|
||||
error: error.clone(),
|
||||
model_visible_error: Some(error),
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn normalize_newlines(value: &str) -> String {
|
||||
let normalized = value.replace("\r\n", "\n");
|
||||
normalized.replace('\r', "\n")
|
||||
}
|
||||
|
||||
fn replace_string(call: &ToolCall, before: &str) -> std::result::Result<String, String> {
|
||||
let old = normalize_newlines(&string(call, "old_string").map_err(|error| error.to_string())?);
|
||||
let new = normalize_newlines(&string(call, "new_string").map_err(|error| error.to_string())?);
|
||||
if old.is_empty() {
|
||||
return Err("old_string must not be empty".into());
|
||||
}
|
||||
let occurrences = before.match_indices(&old).count();
|
||||
let replace_all = call
|
||||
.arguments
|
||||
.get("replace_all")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false);
|
||||
match (replace_all, occurrences) {
|
||||
(_, 0) => Err("old_string was not found".into()),
|
||||
(false, 1) => Ok(before.replacen(&old, &new, 1)),
|
||||
(false, count) => Err(format!(
|
||||
"old_string is not unique; found {count} occurrences"
|
||||
)),
|
||||
(true, _) => Ok(before.replace(&old, &new)),
|
||||
}
|
||||
}
|
||||
|
||||
fn edit_notebook(call: &ToolCall, before: &str) -> std::result::Result<String, String> {
|
||||
let mut notebook: Value =
|
||||
serde_json::from_str(before).map_err(|error| format!("invalid notebook JSON: {error}"))?;
|
||||
let cells = notebook
|
||||
.get_mut("cells")
|
||||
.and_then(Value::as_array_mut)
|
||||
.ok_or_else(|| "notebook has no cells array".to_string())?;
|
||||
let index = call
|
||||
.arguments
|
||||
.get("cell_idx")
|
||||
.and_then(Value::as_u64)
|
||||
.and_then(|value| usize::try_from(value).ok())
|
||||
.ok_or_else(|| "EditNotebook is missing cell_idx".to_string())?;
|
||||
let new = normalize_newlines(&string(call, "new_string").map_err(|error| error.to_string())?);
|
||||
if call
|
||||
.arguments
|
||||
.get("is_new_cell")
|
||||
.and_then(Value::as_bool)
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if index > cells.len() {
|
||||
return Err(format!("cell_idx {index} is past the end of the notebook"));
|
||||
}
|
||||
let language = string(call, "cell_language").map_err(|error| error.to_string())?;
|
||||
let cell_type = if language == "markdown" || language == "raw" {
|
||||
language.as_str()
|
||||
} else {
|
||||
"code"
|
||||
};
|
||||
let mut cell = serde_json::json!({
|
||||
"cell_type": cell_type,
|
||||
"metadata": {},
|
||||
"source": source_lines(&new),
|
||||
});
|
||||
if cell_type == "code" {
|
||||
cell["execution_count"] = Value::Null;
|
||||
cell["outputs"] = Value::Array(Vec::new());
|
||||
}
|
||||
cells.insert(index, cell);
|
||||
} else {
|
||||
let cell = cells
|
||||
.get_mut(index)
|
||||
.ok_or_else(|| format!("cell_idx {index} does not exist"))?;
|
||||
let source = cell
|
||||
.get("source")
|
||||
.map(notebook_source)
|
||||
.transpose()?
|
||||
.unwrap_or_default();
|
||||
let old =
|
||||
normalize_newlines(&string(call, "old_string").map_err(|error| error.to_string())?);
|
||||
let occurrences = source.match_indices(&old).count();
|
||||
let edited = match occurrences {
|
||||
0 => return Err("old_string was not found in the notebook cell".into()),
|
||||
1 => source.replacen(&old, &new, 1),
|
||||
count => {
|
||||
return Err(format!(
|
||||
"old_string is not unique in the notebook cell; found {count} occurrences"
|
||||
))
|
||||
}
|
||||
};
|
||||
cell["source"] = Value::Array(source_lines(&edited));
|
||||
}
|
||||
serde_json::to_string_pretty(¬ebook)
|
||||
.map(|value| format!("{value}\n"))
|
||||
.map_err(|error| error.to_string())
|
||||
}
|
||||
|
||||
fn notebook_source(value: &Value) -> std::result::Result<String, String> {
|
||||
match value {
|
||||
Value::String(value) => Ok(normalize_newlines(value)),
|
||||
Value::Array(lines) => lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.as_str()
|
||||
.ok_or_else(|| "notebook cell source contains a non-string".to_string())
|
||||
})
|
||||
.collect::<std::result::Result<Vec<_>, _>>()
|
||||
.map(|lines| normalize_newlines(&lines.concat())),
|
||||
_ => Err("notebook cell source is not text".into()),
|
||||
}
|
||||
}
|
||||
|
||||
fn source_lines(value: &str) -> Vec<Value> {
|
||||
if value.is_empty() {
|
||||
Vec::new()
|
||||
} else {
|
||||
value
|
||||
.split_inclusive('\n')
|
||||
.map(|line| Value::String(line.to_string()))
|
||||
.collect()
|
||||
}
|
||||
}
|
||||
|
||||
fn string(call: &ToolCall, field: &str) -> Result<String> {
|
||||
call.arguments
|
||||
.get(field)
|
||||
.and_then(Value::as_str)
|
||||
.map(str::to_owned)
|
||||
.ok_or_else(|| Error::Protocol(format!("{} is missing {field}", call.name)))
|
||||
}
|
||||
|
||||
fn normalized(value: &str) -> String {
|
||||
value
|
||||
.chars()
|
||||
.filter(|character| character.is_ascii_alphanumeric())
|
||||
.flat_map(char::to_lowercase)
|
||||
.collect()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use serde_json::json;
|
||||
|
||||
use super::*;
|
||||
|
||||
fn call(name: &str, arguments: Value) -> ToolCall {
|
||||
ToolCall {
|
||||
index: 0,
|
||||
call_id: "call\nfc_1".into(),
|
||||
model_call_id: "model".into(),
|
||||
name: name.into(),
|
||||
arguments_text: String::new(),
|
||||
arguments,
|
||||
}
|
||||
}
|
||||
|
||||
fn read(content: &str) -> pb::ReadResult {
|
||||
pb::ReadResult {
|
||||
result: Some(pb::read_result::Result::Success(pb::ReadSuccess {
|
||||
output: Some(pb::read_success::Output::Content(content.into())),
|
||||
..Default::default()
|
||||
})),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn write_and_str_replace_use_one_lf_canonical_form() {
|
||||
let write = after_read(
|
||||
&call("Write", json!({"path":"/a","contents":"new\rline\r\n"})),
|
||||
&read("old\r\nline\r"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(write.before, "old\nline\n");
|
||||
assert_eq!(write.after, "new\nline\n");
|
||||
|
||||
let replacement = after_read(
|
||||
&call(
|
||||
"StrReplace",
|
||||
json!({"path":"/a","old_string":"old\nline","new_string":"new\r\nline"}),
|
||||
),
|
||||
&read("old\r\nline\r\nrest"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(replacement.after, "new\nline\nrest");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn str_replace_requires_one_match_unless_replace_all_is_explicit() {
|
||||
let ambiguous = after_read(
|
||||
&call(
|
||||
"StrReplace",
|
||||
json!({"path":"/a","old_string":"same","new_string":"new"}),
|
||||
),
|
||||
&read("same\nsame\n"),
|
||||
)
|
||||
.unwrap_err();
|
||||
assert_eq!(ambiguous, "old_string is not unique; found 2 occurrences");
|
||||
|
||||
let all = after_read(
|
||||
&call(
|
||||
"StrReplace",
|
||||
json!({
|
||||
"path":"/a", "old_string":"same", "new_string":"new",
|
||||
"replace_all":true
|
||||
}),
|
||||
),
|
||||
&read("same\rsame\r\n"),
|
||||
)
|
||||
.unwrap();
|
||||
assert_eq!(all.after, "new\nnew\n");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn notebook_edit_targets_one_cell_and_preserves_lf() {
|
||||
let notebook = r#"{"cells":[{"cell_type":"code","source":["old\r\n","line"]}],"metadata":{},"nbformat":4,"nbformat_minor":5}"#;
|
||||
let edit = after_read(
|
||||
&call(
|
||||
"EditNotebook",
|
||||
json!({
|
||||
"target_notebook":"/a.ipynb", "cell_idx":0, "is_new_cell":false,
|
||||
"cell_language":"python", "old_string":"old\nline", "new_string":"new\r\nline"
|
||||
}),
|
||||
),
|
||||
&read(notebook),
|
||||
)
|
||||
.unwrap();
|
||||
let parsed: Value = serde_json::from_str(&edit.after).unwrap();
|
||||
assert_eq!(parsed["cells"][0]["source"], json!(["new\n", "line"]));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
use std::collections::{BTreeMap, HashSet};
|
||||
|
||||
pub mod codec;
|
||||
mod dispatch;
|
||||
pub(crate) mod edit;
|
||||
pub(crate) mod result;
|
||||
pub mod runtime;
|
||||
pub(crate) mod stream;
|
||||
|
||||
use crate::{
|
||||
model::{CanonicalMessage, MessageContent, Role, ToolCall},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use self::result::{ToolCompletion, ToolResultSender};
|
||||
use super::{interaction, proto::agent::v1 as pb};
|
||||
use runtime::{CursorToolRuntime, ExecContext};
|
||||
|
||||
#[derive(Clone)]
|
||||
pub struct ToolDispatcher {
|
||||
runtime: CursorToolRuntime,
|
||||
results: ToolResultSender,
|
||||
}
|
||||
|
||||
pub struct DispatchedTool {
|
||||
pub messages: Vec<pb::AgentServerMessage>,
|
||||
pub completion: Option<ToolCompletion>,
|
||||
}
|
||||
|
||||
pub struct ToolBatchState<'a> {
|
||||
pub completed: &'a HashSet<String>,
|
||||
pub started: &'a HashSet<String>,
|
||||
pub response_text: &'a str,
|
||||
pub response_thinking: &'a str,
|
||||
}
|
||||
|
||||
pub enum ClientToolEvent {
|
||||
Message(Box<pb::AgentServerMessage>),
|
||||
Completed(Box<ToolCompletion>),
|
||||
}
|
||||
|
||||
impl ToolDispatcher {
|
||||
pub fn new(runtime: CursorToolRuntime) -> Self {
|
||||
let (results, _) = result::tool_result_channel();
|
||||
Self::with_results(runtime, results)
|
||||
}
|
||||
|
||||
pub fn with_results(runtime: CursorToolRuntime, results: ToolResultSender) -> Self {
|
||||
Self { runtime, results }
|
||||
}
|
||||
|
||||
pub async fn start_batch(
|
||||
&self,
|
||||
calls: &[ToolCall],
|
||||
state: ToolBatchState<'_>,
|
||||
messages: &[CanonicalMessage],
|
||||
dynamic_mcp: &BTreeMap<String, pb::McpToolDefinition>,
|
||||
context: &ExecContext,
|
||||
) -> Result<Vec<DispatchedTool>> {
|
||||
let first_tool_index = current_turn_step_count(messages)
|
||||
+ usize::from(!state.response_thinking.is_empty())
|
||||
+ usize::from(!state.response_text.is_empty())
|
||||
+ 1;
|
||||
let mut dispatched = Vec::new();
|
||||
for (position, call) in calls.iter().enumerate() {
|
||||
if state.completed.contains(&call.call_id) {
|
||||
continue;
|
||||
}
|
||||
dispatched.push(
|
||||
self.start(
|
||||
call,
|
||||
first_tool_index + position,
|
||||
!state.started.contains(&call.call_id),
|
||||
dynamic_mcp,
|
||||
context,
|
||||
)
|
||||
.await?,
|
||||
);
|
||||
}
|
||||
Ok(dispatched)
|
||||
}
|
||||
|
||||
async fn start(
|
||||
&self,
|
||||
call: &ToolCall,
|
||||
message_index: usize,
|
||||
publish_started: bool,
|
||||
dynamic_mcp: &BTreeMap<String, pb::McpToolDefinition>,
|
||||
context: &ExecContext,
|
||||
) -> Result<DispatchedTool> {
|
||||
let mut messages = if publish_started {
|
||||
vec![interaction::tool_started(call)?]
|
||||
} else {
|
||||
Vec::new()
|
||||
};
|
||||
let started = dispatch::start(
|
||||
&self.runtime,
|
||||
&self.results,
|
||||
call,
|
||||
message_index,
|
||||
dynamic_mcp,
|
||||
context,
|
||||
)
|
||||
.await?;
|
||||
messages.extend(started.messages);
|
||||
Ok(DispatchedTool {
|
||||
messages,
|
||||
completion: started.completion,
|
||||
})
|
||||
}
|
||||
|
||||
pub async fn interaction_response(
|
||||
&self,
|
||||
response: &pb::InteractionResponse,
|
||||
) -> Result<ClientToolEvent> {
|
||||
let pending = match self.runtime.take_interaction(response.id).await {
|
||||
Some(pending) => pending,
|
||||
None if self.runtime.completed_call(response.id).await.is_some() => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"duplicate terminal InteractionResponse id: {}",
|
||||
response.id
|
||||
)));
|
||||
}
|
||||
None => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"unknown InteractionResponse id: {}",
|
||||
response.id
|
||||
)));
|
||||
}
|
||||
};
|
||||
Ok(
|
||||
match dispatch::resume_interaction(&self.runtime, pending, response).await? {
|
||||
dispatch::InteractionContinuation::Message(message) => {
|
||||
ClientToolEvent::Message(message)
|
||||
}
|
||||
dispatch::InteractionContinuation::Completed(completion) => {
|
||||
ClientToolEvent::Completed(completion)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fn current_turn_step_count(messages: &[CanonicalMessage]) -> usize {
|
||||
let turn_start = messages
|
||||
.iter()
|
||||
.rposition(|message| message.role == Role::User)
|
||||
.map_or(0, |position| position + 1);
|
||||
messages[turn_start..]
|
||||
.iter()
|
||||
.map(|message| match &message.content {
|
||||
MessageContent::Assistant {
|
||||
text,
|
||||
thinking,
|
||||
tool_calls,
|
||||
..
|
||||
} => {
|
||||
usize::from(!thinking.is_empty()) + usize::from(!text.is_empty()) + tool_calls.len()
|
||||
}
|
||||
_ => 0,
|
||||
})
|
||||
.sum()
|
||||
}
|
||||
@@ -0,0 +1,143 @@
|
||||
use serde_json::Value;
|
||||
|
||||
use crate::{
|
||||
cursor::proto::agent::v1 as pb,
|
||||
model::{ToolCall, ToolResult},
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::{now_ms, ToolCompletion};
|
||||
use crate::cursor::tools::runtime::{ExecStage, PendingExec};
|
||||
|
||||
pub(crate) fn await_result(
|
||||
pending: PendingExec,
|
||||
output_length: u64,
|
||||
regex_match: Option<String>,
|
||||
exit_code: Option<i32>,
|
||||
) -> Result<ToolCompletion> {
|
||||
let ExecStage::Await(state) = &pending.stage else {
|
||||
return Err(Error::Protocol(
|
||||
"AwaitShell completion reached a non-await execution stage".into(),
|
||||
));
|
||||
};
|
||||
let runtime_ms = now_ms().saturating_sub(pending.started_at_ms);
|
||||
let result = if exit_code.is_some() {
|
||||
pb::await_success::AwaitResult::Complete(pb::AwaitTaskComplete {
|
||||
task_id: state.task_id.clone(),
|
||||
runtime_ms,
|
||||
output_file_path: state.output_file_path.clone(),
|
||||
output_length,
|
||||
regex_requested: state.regex.is_some(),
|
||||
regex_match,
|
||||
exit_code,
|
||||
wake_reason: Some("task_complete".into()),
|
||||
})
|
||||
} else {
|
||||
pb::await_success::AwaitResult::StillRunning(pb::AwaitTaskStillRunning {
|
||||
task_id: state.task_id.clone(),
|
||||
runtime_ms,
|
||||
output_file_path: state.output_file_path.clone(),
|
||||
output_length,
|
||||
regex_requested: state.regex.is_some(),
|
||||
regex_match,
|
||||
wake_reason: Some("timeout_or_pattern".into()),
|
||||
})
|
||||
};
|
||||
let content = serde_json::json!({
|
||||
"task_id": state.task_id,
|
||||
"output_file_path": state.output_file_path,
|
||||
"output_length": output_length,
|
||||
"exit_code": exit_code,
|
||||
})
|
||||
.to_string();
|
||||
completion(
|
||||
&pending,
|
||||
content,
|
||||
false,
|
||||
pb::await_result::Result::Success(pb::AwaitSuccess {
|
||||
await_result: Some(result),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn await_error(pending: PendingExec, error: &str) -> Result<ToolCompletion> {
|
||||
completion(
|
||||
&pending,
|
||||
error.into(),
|
||||
true,
|
||||
pb::await_result::Result::Error(pb::AwaitError {
|
||||
error: error.into(),
|
||||
}),
|
||||
)
|
||||
}
|
||||
|
||||
fn completion(
|
||||
pending: &PendingExec,
|
||||
content: String,
|
||||
is_error: bool,
|
||||
result: pb::await_result::Result,
|
||||
) -> Result<ToolCompletion> {
|
||||
let ExecStage::Await(state) = &pending.stage else {
|
||||
return Err(Error::Protocol(
|
||||
"AwaitShell completion reached a non-await execution stage".into(),
|
||||
));
|
||||
};
|
||||
Ok(ToolCompletion::new(
|
||||
&pending.call,
|
||||
pending.started_at_ms,
|
||||
ToolResult {
|
||||
call_id: pending.call.call_id.clone(),
|
||||
content,
|
||||
is_error,
|
||||
},
|
||||
pb::tool_call::Tool::AwaitToolCall(pb::AwaitToolCall {
|
||||
args: Some(pb::AwaitArgs {
|
||||
task_id: state.task_id.clone(),
|
||||
block_until_ms: pending
|
||||
.call
|
||||
.arguments
|
||||
.get("block_until_ms")
|
||||
.and_then(Value::as_u64)
|
||||
.map(|value| value as u32),
|
||||
regex: state.regex.clone(),
|
||||
}),
|
||||
result: Some(pb::AwaitResult {
|
||||
result: Some(result),
|
||||
}),
|
||||
}),
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn await_sleep(call: &ToolCall, runtime_ms: u64) -> ToolCompletion {
|
||||
ToolCompletion::new(
|
||||
call,
|
||||
now_ms().saturating_sub(runtime_ms),
|
||||
ToolResult {
|
||||
call_id: call.call_id.clone(),
|
||||
content: format!("Waited {runtime_ms} ms"),
|
||||
is_error: false,
|
||||
},
|
||||
pb::tool_call::Tool::AwaitToolCall(pb::AwaitToolCall {
|
||||
args: Some(pb::AwaitArgs {
|
||||
task_id: String::new(),
|
||||
block_until_ms: Some(runtime_ms as u32),
|
||||
regex: None,
|
||||
}),
|
||||
result: Some(pb::AwaitResult {
|
||||
result: Some(pb::await_result::Result::Success(pb::AwaitSuccess {
|
||||
await_result: Some(pb::await_success::AwaitResult::StillRunning(
|
||||
pb::AwaitTaskStillRunning {
|
||||
task_id: String::new(),
|
||||
runtime_ms,
|
||||
output_file_path: String::new(),
|
||||
output_length: 0,
|
||||
regex_requested: false,
|
||||
regex_match: None,
|
||||
wake_reason: Some("sleep_complete".into()),
|
||||
},
|
||||
)),
|
||||
})),
|
||||
}),
|
||||
}),
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,110 @@
|
||||
mod output;
|
||||
mod render;
|
||||
|
||||
use crate::{
|
||||
cursor::{interaction, proto::agent::v1 as pb},
|
||||
model::ToolResult,
|
||||
Error, Result,
|
||||
};
|
||||
|
||||
use super::{mcp_state, ToolCompletion};
|
||||
use crate::cursor::tools::{
|
||||
edit,
|
||||
runtime::{ExecStage, PendingExec},
|
||||
};
|
||||
|
||||
pub(crate) fn from_exec(
|
||||
pending: PendingExec,
|
||||
wire_result: &pb::exec_client_message::Message,
|
||||
) -> Result<ToolCompletion> {
|
||||
use pb::{exec_client_message::Message, tool_call::Tool};
|
||||
if let Message::McpStateExecResult(result) = wire_result {
|
||||
return mcp_state::complete(pending, result);
|
||||
}
|
||||
let call = &pending.call;
|
||||
let (content, is_error) = output::output(wire_result, call)?;
|
||||
let mut rendered = interaction::render_tool_call(call, false)?;
|
||||
match (rendered.tool.as_mut(), wire_result) {
|
||||
(Some(Tool::ShellToolCall(tool)), Message::ShellResult(result))
|
||||
| (Some(Tool::ShellToolCall(tool)), Message::MiniSweAgentBashResult(result)) => {
|
||||
tool.result = Some(result.clone());
|
||||
}
|
||||
(Some(Tool::DeleteToolCall(tool)), Message::DeleteResult(result)) => {
|
||||
tool.result = Some(result.clone());
|
||||
}
|
||||
(Some(Tool::GrepToolCall(tool)), Message::GrepResult(result)) => {
|
||||
tool.result = Some(result.clone());
|
||||
}
|
||||
(Some(Tool::GlobToolCall(tool)), Message::GrepResult(result)) => {
|
||||
tool.result = Some(render::glob(result)?);
|
||||
}
|
||||
(Some(Tool::ReadToolCall(tool)), Message::ReadResult(result))
|
||||
| (Some(Tool::ReadToolCall(tool)), Message::RedactedReadResult(result)) => {
|
||||
tool.result = Some(render::read(result, call)?);
|
||||
}
|
||||
(Some(Tool::ReadLintsToolCall(tool)), Message::DiagnosticsResult(result)) => {
|
||||
tool.result = Some(render::diagnostics(result)?);
|
||||
}
|
||||
(Some(Tool::McpToolCall(tool)), Message::McpResult(result)) => {
|
||||
tool.result = Some(render::mcp(result)?);
|
||||
}
|
||||
(Some(Tool::ReadMcpResourceToolCall(tool)), Message::ReadMcpResourceExecResult(result)) => {
|
||||
tool.result = Some(result.clone());
|
||||
}
|
||||
(Some(Tool::WebFetchToolCall(tool)), Message::FetchResult(result)) => {
|
||||
tool.result = Some(render::web_fetch(result)?);
|
||||
}
|
||||
(Some(Tool::TaskToolCall(tool)), Message::SubagentResult(result)) => {
|
||||
tool.result = Some(render::task(result)?);
|
||||
}
|
||||
(Some(Tool::EditToolCall(tool)), Message::WriteResult(result)) => {
|
||||
tool.result = Some(match (&pending.stage, result.result.as_ref()) {
|
||||
(ExecStage::EditWrite(write), Some(pb::write_result::Result::Success(success))) => {
|
||||
edit::success(success.path.clone(), write)
|
||||
}
|
||||
_ => render::write(result)?,
|
||||
});
|
||||
}
|
||||
_ => {
|
||||
return Err(Error::Protocol(format!(
|
||||
"unexpected Exec result for tool {}",
|
||||
call.name
|
||||
)));
|
||||
}
|
||||
}
|
||||
let tool = rendered.tool.ok_or_else(|| {
|
||||
Error::Protocol(format!("tool {} has no Cursor representation", call.name))
|
||||
})?;
|
||||
Ok(ToolCompletion::new(
|
||||
call,
|
||||
pending.started_at_ms,
|
||||
ToolResult {
|
||||
call_id: call.call_id.clone(),
|
||||
content,
|
||||
is_error,
|
||||
},
|
||||
tool,
|
||||
))
|
||||
}
|
||||
|
||||
pub(crate) fn edit_failure(pending: PendingExec, error: String) -> Result<ToolCompletion> {
|
||||
let call = &pending.call;
|
||||
let mut rendered = interaction::render_tool_call(call, false)?;
|
||||
let Some(pb::tool_call::Tool::EditToolCall(mut tool)) = rendered.tool.take() else {
|
||||
return Err(Error::Protocol(format!(
|
||||
"{} is not an edit tool",
|
||||
call.name
|
||||
)));
|
||||
};
|
||||
tool.result = Some(edit::failure(edit::path(call)?, error.clone()));
|
||||
Ok(ToolCompletion::new(
|
||||
call,
|
||||
pending.started_at_ms,
|
||||
ToolResult {
|
||||
call_id: call.call_id.clone(),
|
||||
content: error,
|
||||
is_error: true,
|
||||
},
|
||||
pb::tool_call::Tool::EditToolCall(tool),
|
||||
))
|
||||
}
|
||||
@@ -0,0 +1,278 @@
|
||||
use crate::{cursor::proto::agent::v1 as pb, model::ToolCall, Error, Result};
|
||||
|
||||
pub(super) fn output(
|
||||
message: &pb::exec_client_message::Message,
|
||||
call: &ToolCall,
|
||||
) -> Result<(String, bool)> {
|
||||
use pb::exec_client_message::Message;
|
||||
match message {
|
||||
Message::ShellResult(value) | Message::MiniSweAgentBashResult(value) => shell(value),
|
||||
Message::ReadResult(value) | Message::RedactedReadResult(value) => read(value),
|
||||
Message::WriteResult(value) => write(value),
|
||||
Message::DeleteResult(value) => delete(value),
|
||||
Message::GrepResult(value) => grep(value),
|
||||
Message::DiagnosticsResult(value) => diagnostics(value),
|
||||
Message::McpResult(value) => mcp(value),
|
||||
Message::ReadMcpResourceExecResult(value) => read_mcp(value),
|
||||
Message::FetchResult(value) => fetch(value),
|
||||
Message::SubagentResult(value) => task(value, call),
|
||||
_ => Err(Error::Protocol(
|
||||
"unsupported terminal ExecClientMessage".into(),
|
||||
)),
|
||||
}
|
||||
}
|
||||
|
||||
fn shell(value: &pb::ShellResult) -> Result<(String, bool)> {
|
||||
use pb::shell_result::Result as R;
|
||||
let output = match value.result.as_ref().ok_or_else(|| missing("shell"))? {
|
||||
R::Success(success) if value.is_background == Some(true) => {
|
||||
let mut fields = vec![format!("shell_id={}", success.shell_id.unwrap_or_default())];
|
||||
if let Some(pid) = success.pid.or(value.pid) {
|
||||
fields.push(format!("pid={pid}"));
|
||||
}
|
||||
if let Some(folder) = value.terminals_folder.as_deref().filter(|v| !v.is_empty()) {
|
||||
fields.push(format!("terminals_folder={folder}"));
|
||||
}
|
||||
let output = streams(&success.stdout, &success.stderr);
|
||||
let prefix = format!("shell running in background {}", fields.join(" "));
|
||||
return Ok((
|
||||
if output == "shell completed without output" {
|
||||
prefix
|
||||
} else {
|
||||
format!("{prefix}\n{output}")
|
||||
},
|
||||
false,
|
||||
));
|
||||
}
|
||||
R::Success(success) => return Ok((streams(&success.stdout, &success.stderr), false)),
|
||||
R::Failure(failure) => streams(&failure.stdout, &failure.stderr),
|
||||
R::Timeout(timeout) => format!(
|
||||
"shell timed out after {}ms in {}",
|
||||
timeout.timeout_ms, timeout.working_directory
|
||||
),
|
||||
R::Rejected(rejected) => rejected.reason.clone(),
|
||||
R::SpawnError(error) => error.error.clone(),
|
||||
R::PermissionDenied(denied) => denied.error.clone(),
|
||||
};
|
||||
Ok((output, true))
|
||||
}
|
||||
|
||||
fn streams(stdout: &str, stderr: &str) -> String {
|
||||
match (stdout.is_empty(), stderr.is_empty()) {
|
||||
(false, false) => format!("{stdout}\n\n<stderr>\n{stderr}\n</stderr>"),
|
||||
(false, true) => stdout.into(),
|
||||
(true, false) => stderr.into(),
|
||||
(true, true) => "shell completed without output".into(),
|
||||
}
|
||||
}
|
||||
|
||||
fn read(value: &pb::ReadResult) -> Result<(String, bool)> {
|
||||
use pb::{read_result::Result as R, read_success::Output};
|
||||
match value.result.as_ref().ok_or_else(|| missing("read"))? {
|
||||
R::Success(success) => Ok((
|
||||
match success.output.as_ref() {
|
||||
Some(Output::Content(text)) => text.clone(),
|
||||
Some(Output::Data(bytes)) => format!("read binary bytes={}", bytes.len()),
|
||||
None => format!("read success path={}", success.path),
|
||||
},
|
||||
false,
|
||||
)),
|
||||
R::Error(error) => Ok((error.error.clone(), true)),
|
||||
R::Rejected(rejected) => Ok((rejected.reason.clone(), true)),
|
||||
R::FileNotFound(value) => Ok((format!("file not found: {}", value.path), true)),
|
||||
R::PermissionDenied(value) => Ok((format!("permission denied: {}", value.path), true)),
|
||||
R::InvalidFile(value) => Ok((value.reason.clone(), true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn write(value: &pb::WriteResult) -> Result<(String, bool)> {
|
||||
use pb::write_result::Result as R;
|
||||
match value.result.as_ref().ok_or_else(|| missing("write"))? {
|
||||
R::Success(success) => Ok((
|
||||
success.file_content_after_write.clone().unwrap_or_else(|| {
|
||||
format!(
|
||||
"write success path={} lines={}",
|
||||
success.path, success.lines_created
|
||||
)
|
||||
}),
|
||||
false,
|
||||
)),
|
||||
R::PermissionDenied(value) => Ok((value.error.clone(), true)),
|
||||
R::NoSpace(value) => Ok((format!("no space left: {}", value.path), true)),
|
||||
R::Error(value) => Ok((value.error.clone(), true)),
|
||||
R::Rejected(value) => Ok((value.reason.clone(), true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn delete(value: &pb::DeleteResult) -> Result<(String, bool)> {
|
||||
use pb::delete_result::Result as R;
|
||||
match value.result.as_ref().ok_or_else(|| missing("delete"))? {
|
||||
R::Success(value) => Ok((format!("delete success path={}", value.path), false)),
|
||||
R::FileNotFound(value) => Ok((format!("file not found: {}", value.path), true)),
|
||||
R::NotFile(value) => Ok((format!("not file: {}", value.path), true)),
|
||||
R::PermissionDenied(value) => Ok((value.client_visible_error.clone(), true)),
|
||||
R::FileBusy(value) => Ok((format!("file busy: {}", value.path), true)),
|
||||
R::Rejected(value) => Ok((value.reason.clone(), true)),
|
||||
R::Error(value) => Ok((value.error.clone(), true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn grep(value: &pb::GrepResult) -> Result<(String, bool)> {
|
||||
use pb::grep_result::Result as R;
|
||||
match value.result.as_ref().ok_or_else(|| missing("grep"))? {
|
||||
R::Success(value) => Ok((
|
||||
format!(
|
||||
"grep success pattern={} mode={}",
|
||||
value.pattern, value.output_mode
|
||||
),
|
||||
false,
|
||||
)),
|
||||
R::Error(value) => Ok((value.error.clone(), true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn diagnostics(value: &pb::DiagnosticsResult) -> Result<(String, bool)> {
|
||||
use pb::diagnostics_result::Result as R;
|
||||
match value
|
||||
.result
|
||||
.as_ref()
|
||||
.ok_or_else(|| missing("diagnostics"))?
|
||||
{
|
||||
R::Success(value) => Ok((
|
||||
format!(
|
||||
"diagnostics path={} count={}",
|
||||
value.path, value.total_diagnostics
|
||||
),
|
||||
false,
|
||||
)),
|
||||
R::Error(value) => Ok((value.error.clone(), true)),
|
||||
R::Rejected(value) => Ok((value.reason.clone(), true)),
|
||||
R::FileNotFound(value) => Ok((format!("file not found: {}", value.path), true)),
|
||||
R::PermissionDenied(value) => Ok((format!("permission denied: {}", value.path), true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp(value: &pb::McpResult) -> Result<(String, bool)> {
|
||||
use pb::mcp_result::Result as R;
|
||||
match value.result.as_ref().ok_or_else(|| missing("mcp"))? {
|
||||
R::Success(value) => Ok((mcp_content(value)?, value.is_error)),
|
||||
R::Error(value) => Ok((value.error.clone(), true)),
|
||||
R::Rejected(value) => Ok((value.reason.clone(), true)),
|
||||
R::PermissionDenied(value) => Ok((value.error.clone(), true)),
|
||||
R::ToolNotFound(value) => Ok((format!("MCP tool not found: {}", value.name), true)),
|
||||
R::ServerNotFound(value) => Ok((format!("MCP server not found: {}", value.name), true)),
|
||||
R::Approved(_) => Err(Error::Protocol("MCP approval is not terminal".into())),
|
||||
}
|
||||
}
|
||||
|
||||
fn mcp_content(success: &pb::McpSuccess) -> Result<String> {
|
||||
let mut content = Vec::new();
|
||||
for item in &success.content {
|
||||
match item.content.as_ref() {
|
||||
Some(pb::mcp_tool_result_content_item::Content::Text(text)) => {
|
||||
if !text.text.is_empty() {
|
||||
content.push(text.text.clone());
|
||||
}
|
||||
if let Some(location) = &text.output_location {
|
||||
content.push(format!(
|
||||
"MCP output file: {} ({} bytes, {} lines)",
|
||||
location.file_path, location.size_bytes, location.line_count
|
||||
));
|
||||
}
|
||||
}
|
||||
Some(pb::mcp_tool_result_content_item::Content::Image(image)) => content.push(format!(
|
||||
"MCP image: {} ({} bytes)",
|
||||
image.mime_type,
|
||||
image.data.len()
|
||||
)),
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
if let Some(structured) = &success.structured_content {
|
||||
let value = serde_json::Value::Object(
|
||||
structured
|
||||
.fields
|
||||
.iter()
|
||||
.map(|(key, value)| (key.clone(), super::super::prost_json(value)))
|
||||
.collect(),
|
||||
);
|
||||
content.push(serde_json::to_string_pretty(&value)?);
|
||||
}
|
||||
Ok(if content.is_empty() {
|
||||
"MCP tool completed without content".into()
|
||||
} else {
|
||||
content.join("\n\n")
|
||||
})
|
||||
}
|
||||
|
||||
fn read_mcp(value: &pb::ReadMcpResourceExecResult) -> Result<(String, bool)> {
|
||||
use pb::read_mcp_resource_exec_result::Result as R;
|
||||
match value
|
||||
.result
|
||||
.as_ref()
|
||||
.ok_or_else(|| missing("read MCP resource"))?
|
||||
{
|
||||
R::Success(value) => Ok((
|
||||
match value.content.as_ref() {
|
||||
Some(pb::read_mcp_resource_success::Content::Text(text)) => text.clone(),
|
||||
Some(pb::read_mcp_resource_success::Content::Blob(blob)) => {
|
||||
format!("read MCP resource blob={}", blob.len())
|
||||
}
|
||||
None => format!("read MCP resource uri={}", value.uri),
|
||||
},
|
||||
false,
|
||||
)),
|
||||
R::Error(value) => Ok((value.error.clone(), true)),
|
||||
R::Rejected(value) => Ok((value.reason.clone(), true)),
|
||||
R::NotFound(value) => Ok((format!("MCP resource not found: {}", value.uri), true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn fetch(value: &pb::FetchResult) -> Result<(String, bool)> {
|
||||
use pb::fetch_result::Result as R;
|
||||
match value.result.as_ref().ok_or_else(|| missing("web fetch"))? {
|
||||
R::Success(value) => Ok((value.content.clone(), false)),
|
||||
R::Error(value) => Ok((value.error.clone(), true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn task(value: &pb::SubagentResult, call: &ToolCall) -> Result<(String, bool)> {
|
||||
use pb::subagent_result::Result as R;
|
||||
match value.result.as_ref().ok_or_else(|| missing("subagent"))? {
|
||||
R::Success(value) if creates_subagent(call) => {
|
||||
let name = call
|
||||
.arguments
|
||||
.get("description")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.filter(|name| !name.is_empty())
|
||||
.ok_or_else(|| Error::Protocol("Task call is missing description".into()))?;
|
||||
if value.agent_id.is_empty() {
|
||||
return Err(Error::Protocol("Task result is missing agent_id".into()));
|
||||
}
|
||||
let identity = format!("Subagent name: {name}\nSubagent ID: {}", value.agent_id);
|
||||
let content = value
|
||||
.final_message
|
||||
.as_deref()
|
||||
.filter(|message| !message.is_empty())
|
||||
.map_or(identity.clone(), |message| {
|
||||
format!("{identity}\n\n{message}")
|
||||
});
|
||||
Ok((content, false))
|
||||
}
|
||||
R::Success(value) => Ok((value.final_message.clone().unwrap_or_default(), false)),
|
||||
R::Error(value) => Ok((value.error.clone(), true)),
|
||||
}
|
||||
}
|
||||
|
||||
fn creates_subagent(call: &ToolCall) -> bool {
|
||||
matches!(
|
||||
call.arguments
|
||||
.get("resume")
|
||||
.and_then(serde_json::Value::as_str),
|
||||
None | Some("self")
|
||||
)
|
||||
}
|
||||
|
||||
fn missing(name: &str) -> Error {
|
||||
Error::Protocol(format!("{name} returned no result"))
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user