all tools

This commit is contained in:
leookun
2026-08-16 17:29:29 +08:00
parent eafede22e3
commit 4db2061611
95 changed files with 19023 additions and 5637 deletions
+2876
View File
File diff suppressed because it is too large Load Diff
+38
View File
@@ -0,0 +1,38 @@
[package]
name = "cursor-server"
version = "0.1.0"
edition = "2021"
publish = false
[dependencies]
async-stream = "0.3"
axum = "0.8"
base64 = "0.22"
bytes = "1"
eventsource-stream = "0.2"
futures-util = "0.3"
hex = "0.4"
include_dir = "0.7"
prost = "0.13"
prost-types = "0.13"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "stream"] }
serde = { version = "1", features = ["derive"] }
serde_json = "1"
sha2 = "0.10"
sqlx = { version = "0.8", features = ["runtime-tokio", "sqlite"] }
thiserror = "2"
tokio = { version = "1", features = ["macros", "rt-multi-thread", "signal", "sync", "time", "net"] }
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"] }
[build-dependencies]
prost-build = "0.13"
protoc-bin-vendored = "3"
[dev-dependencies]
flate2 = "1"
tempfile = "3"
tower = { version = "0.5", features = ["util"] }
+51
View File
@@ -0,0 +1,51 @@
# cursor-server
Cursor Agent 的 Rust 服务端。它实现 `RunSSE + BidiAppend` 通信、无状态 LLM loop、客户端工具执行、Blob/KV 同步和可恢复 checkpoint。
## 启动
首次运行需要安装 Rust stable 工具链。macOS 使用 Homebrew
```bash
brew install rustup
export PATH="$(brew --prefix rustup)/bin:$PATH"
rustup default stable
cargo --version
```
`rustup` 是 keg-only;若要让后续 zsh 会话也能找到 `cargo`,将下面一行加入 `~/.zshrc`,然后重新打开终端:
```bash
export PATH="$(brew --prefix rustup)/bin:$HOME/.cargo/bin:$PATH"
```
进入 `cursor-server` 后启动:
```bash
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`
## 不变量
- `store/messages.rs` 是上下文唯一事实源;消息只追加,不原地修改。
- Runtime tag 使用稳定事件 ID,事务内 exactly-once 追加。
- 每轮投射结果可复现,后一轮 messages 严格以前一轮为前缀。
- 工具每完成一个,就按实际完成顺序原子追加一组 `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 前可重复发送。
- 新 Run 通过 conversation revision 使旧 Run 的迟到事件失效。
- 每种工具只对应一个 Exec、Interaction 或 Local 通道;不存在级联 fallback。
- Loop 不保存工具名称路由;`cursor/tools.rs` 是唯一 transport dispatcher。
- Interaction approval 不是 ToolResult;只有 typed terminal result 才能进入持久化与 checkpoint。
- Pending 项存在即 Running,终态通过 `take(id)` 一次消费;不维护重复的 finished/closed 标志。
- prompt 资产编译进二进制并在启动时整体校验,不与运行时目录逐文件混用。
模块边界和目录是实现约束,必须与仓库根目录 README 保持一致。
+53
View File
@@ -0,0 +1,53 @@
use std::{env, path::PathBuf};
fn main() {
let manifest = PathBuf::from(env::var("CARGO_MANIFEST_DIR").expect("manifest directory"));
let proto_dir = manifest.join("../cursor-proto/proto");
let protos = [proto_dir.join("agent_v1.proto")];
let aiserver_proto = proto_dir.join("aiserver_v1.proto");
env::set_var(
"PROTOC",
protoc_bin_vendored::protoc_bin_path().expect("vendored protoc"),
);
prost_build::Config::new()
.compile_protos(
&protos,
&[
proto_dir.clone(),
protoc_bin_vendored::include_path().expect("vendored protobuf includes"),
],
)
.expect("compile Cursor protobuf schema");
for proto in protos {
println!("cargo:rerun-if-changed={}", proto.display());
}
let aiserver_source = std::fs::read_to_string(&aiserver_proto).expect("read aiserver_v1.proto");
for required in [
"message BidiAppendRequest",
"string data = 1;",
"BidiRequestId request_id = 2;",
"int64 append_seqno = 3;",
"bytes data_binary = 4;",
"message BidiAppendResponse",
"message CustomErrorDetails",
"optional bool is_retryable = 4;",
"optional bool show_request_id = 5;",
"optional bool should_show_immediate_error = 6;",
"message ErrorDetails",
"ERROR_PROVIDER_ERROR = 57;",
"CustomErrorDetails details = 2;",
"optional bool is_expected = 3;",
] {
assert!(
aiserver_source.contains(required),
"aiserver Bidi wire schema changed: missing {required}"
);
}
// The extracted aiserver file currently contains unrelated duplicate message names, so
// compiling that entire package would generate invalid Rust. `cursor/proto.rs` defines only
// the validated Bidi and ErrorDetails wire subsets; agent_v1.proto remains fully generated.
println!("cargo:rerun-if-changed={}", aiserver_proto.display());
}
Binary file not shown.
+93
View File
@@ -0,0 +1,93 @@
PRAGMA foreign_keys = ON;
CREATE TABLE IF NOT EXISTS conversations (
conversation_id TEXT PRIMARY KEY,
revision INTEGER NOT NULL DEFAULT 0,
head_blob_id BLOB,
updated_at_ms INTEGER NOT NULL
);
CREATE TABLE IF NOT EXISTS 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),
FOREIGN KEY (conversation_id) REFERENCES conversations(conversation_id)
);
CREATE INDEX IF NOT EXISTS messages_conversation_seq
ON messages(conversation_id, message_seq);
CREATE TABLE IF NOT EXISTS blobs (
blob_id BLOB PRIMARY KEY CHECK(length(blob_id) = 32),
data BLOB NOT NULL,
created_at_ms INTEGER NOT NULL,
CHECK(length(data) >= 0)
);
CREATE TABLE IF NOT EXISTS blob_edges (
parent_blob_id BLOB NOT NULL,
child_blob_id BLOB NOT NULL,
field_name TEXT NOT NULL,
PRIMARY KEY (parent_blob_id, child_blob_id, field_name),
FOREIGN KEY (parent_blob_id) REFERENCES blobs(blob_id),
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);
+61
View File
@@ -0,0 +1,61 @@
use tokio::net::TcpListener;
use crate::{
config::Config,
cursor::handlers,
prompting::{PromptAssets, PromptCompiler},
provider::build_provider,
run::RunRegistry,
store::Store,
Result,
};
pub struct App {
config: Config,
router: axum::Router,
registry: RunRegistry,
}
impl App {
pub async fn new(config: Config) -> Result<Self> {
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());
Ok(Self {
router: handlers::router(registry.clone()),
registry,
config,
})
}
pub async fn serve(self) -> Result<()> {
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?;
Ok(())
}
}
async fn shutdown_signal(registry: RunRegistry) {
let ctrl_c = async {
let _ = tokio::signal::ctrl_c().await;
};
#[cfg(unix)]
let terminate = async {
if let Ok(mut signal) =
tokio::signal::unix::signal(tokio::signal::unix::SignalKind::terminate())
{
signal.recv().await;
}
};
#[cfg(not(unix))]
let terminate = std::future::pending::<()>();
tokio::select! { _ = ctrl_c => {}, _ = terminate => {} }
tracing::info!("shutdown signal received; cancelling active runs");
registry.shutdown().await;
}
+77
View File
@@ -0,0 +1,77 @@
use std::{env, net::SocketAddr, str::FromStr, time::Duration};
use crate::{Error, Result};
#[derive(Clone, Debug, PartialEq, Eq)]
pub enum ProviderKind {
OpenAiChat,
OpenAiResponses,
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 request_timeout: Duration,
}
#[derive(Clone)]
pub struct Config {
pub listen_addr: SocketAddr,
pub database_url: String,
pub provider: ProviderConfig,
}
impl Config {
pub fn from_env() -> Result<Self> {
let listen_addr = env::var("CURSOR_LISTEN_ADDR")
.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",
};
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),
),
},
})
}
}
+48
View File
@@ -0,0 +1,48 @@
use prost::Message;
use crate::{
cursor::proto::{agent::v1 as agent, aiserver::v1 as ai},
run::{RunCommand, RunRegistry},
Error, Result,
};
pub async fn append(
registry: &RunRegistry,
request: ai::BidiAppendRequest,
) -> Result<ai::BidiAppendResponse> {
let request_id = request
.request_id
.as_ref()
.map(|id| id.request_id.as_str())
.filter(|id| !id.is_empty())
.ok_or_else(|| Error::Protocol("BidiAppend request_id is required".into()))?;
if !request.data_binary.is_empty() {
return Err(Error::Protocol(
"BidiAppend data_binary is not part of the captured protocol".into(),
));
}
if request.data.is_empty() {
return Err(Error::Protocol(
"BidiAppend contains no AgentClientMessage".into(),
));
}
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;
}
}
registry
.get_or_create(request_id)
.await?
.command(RunCommand::Append {
seqno: request.append_seqno,
message: Box::new(message),
})
.await?;
Ok(ai::BidiAppendResponse {})
}
+248
View File
@@ -0,0 +1,248 @@
use std::{
collections::HashMap,
sync::{
atomic::{AtomicU32, Ordering},
Arc,
},
time::Duration,
};
use prost::Message;
use tokio::sync::{oneshot, Mutex, Notify};
use crate::{
cursor::proto::agent::v1 as pb,
run::RunHandle,
store::{BlobEdge, BlobId, Store},
Error, Result,
};
type BlobGetSender = oneshot::Sender<Result<Option<Vec<u8>>>>;
#[derive(Clone)]
pub struct BlobSynchronizer {
inner: Arc<Inner>,
}
struct Inner {
request_id: String,
store: Store,
handle: RunHandle,
next_id: AtomicU32,
set_requests: Mutex<HashMap<u32, BlobId>>,
get_requests: Mutex<HashMap<u32, BlobGetSender>>,
ack: Notify,
}
impl BlobSynchronizer {
pub fn new(request_id: String, store: Store, handle: RunHandle) -> Self {
Self {
inner: Arc::new(Inner {
request_id,
store,
handle,
next_id: AtomicU32::new(1),
set_requests: Mutex::new(HashMap::new()),
get_requests: Mutex::new(HashMap::new()),
ack: Notify::new(),
}),
}
}
pub fn request_id(&self) -> &str {
&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),
}
}
}
pub async fn get(&self, blob_id: &BlobId) -> Result<Option<Vec<u8>>> {
if let Some(data) = self.inner.store.get_blob(blob_id).await? {
return Ok(Some(data));
}
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.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::GetBlobArgs(
pb::GetBlobArgs {
blob_id: blob_id.as_bytes().to_vec(),
},
)),
},
)),
})?;
let cancellation = self.inner.handle.cancellation();
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()))),
}
}
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();
}
}
}
Some(pb::kv_client_message::Message::GetBlobResult(result)) => {
if let Some(sender) = 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 {
Ok(result.blob_data)
};
let _ = sender.send(value);
}
}
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(())
}
}
+384
View File
@@ -0,0 +1,384 @@
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()),
}
}
+121
View File
@@ -0,0 +1,121 @@
use bytes::{BufMut, Bytes, BytesMut};
use prost::Message;
use serde::Serialize;
use crate::{Error, Result};
pub const END_STREAM_FLAG: u8 = 0x02;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum ConnectCode {
Canceled,
InvalidArgument,
NotFound,
Unavailable,
Internal,
}
impl ConnectCode {
fn as_str(self) -> &'static str {
match self {
Self::Canceled => "canceled",
Self::InvalidArgument => "invalid_argument",
Self::NotFound => "not_found",
Self::Unavailable => "unavailable",
Self::Internal => "internal",
}
}
}
#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
pub struct ConnectErrorDetail {
#[serde(rename = "type")]
pub type_name: String,
pub value: String,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct ConnectStreamError {
pub code: ConnectCode,
pub message: String,
pub details: Vec<ConnectErrorDetail>,
}
#[derive(Serialize)]
struct EndStreamResponse<'a> {
error: WireError<'a>,
}
#[derive(Serialize)]
struct WireError<'a> {
code: &'static str,
#[serde(skip_serializing_if = "str::is_empty")]
message: &'a str,
#[serde(skip_serializing_if = "details_are_empty")]
details: &'a [ConnectErrorDetail],
}
fn details_are_empty(details: &&[ConnectErrorDetail]) -> bool {
details.is_empty()
}
pub fn encode_message<M: Message>(message: &M) -> Result<Bytes> {
let len = message.encoded_len();
let mut output = BytesMut::with_capacity(5 + len);
output.put_u8(0);
output.put_u32(len as u32);
message.encode(&mut output)?;
Ok(output.freeze())
}
pub fn encode_end_stream() -> Bytes {
encode_end_stream_payload(b"{}")
}
pub fn encode_error_end_stream(error: &ConnectStreamError) -> Result<Bytes> {
let payload = serde_json::to_vec(&EndStreamResponse {
error: WireError {
code: error.code.as_str(),
message: &error.message,
details: &error.details,
},
})?;
Ok(encode_end_stream_payload(&payload))
}
fn encode_end_stream_payload(payload: &[u8]) -> Bytes {
let mut output = BytesMut::with_capacity(5 + payload.len());
output.put_u8(END_STREAM_FLAG);
output.put_u32(payload.len() as u32);
output.extend_from_slice(payload);
output.freeze()
}
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;
if flags & END_STREAM_FLAG == 0 && length == body.len() - 5 {
return Ok(M::decode(&body[5..])?);
}
}
Ok(M::decode(body)?)
}
pub fn decode_frames(mut body: &[u8]) -> Result<Vec<(u8, Bytes)>> {
let mut frames = Vec::new();
while !body.is_empty() {
if body.len() < 5 {
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;
body = &body[5..];
if body.len() < length {
return Err(Error::Protocol("truncated Connect payload".into()));
}
frames.push((flags, Bytes::copy_from_slice(&body[..length])));
body = &body[length..];
}
Ok(frames)
}
+535
View File
@@ -0,0 +1,535 @@
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) }
}
+53
View File
@@ -0,0 +1,53 @@
use axum::{
body::Bytes,
extract::{DefaultBodyLimit, State},
http::{header, HeaderValue, Response, StatusCode},
routing::post,
Router,
};
use tower_http::decompression::RequestDecompressionLayer;
use crate::{
cursor::{
bidi_append, connect,
proto::{agent::v1 as agent, aiserver::v1 as ai},
run_sse,
},
run::RunRegistry,
Result,
};
pub fn router(registry: RunRegistry) -> Router {
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)
}
async fn run_sse_handler(
State(registry): State<RunRegistry>,
body: Bytes,
) -> Result<Response<axum::body::Body>> {
let request: agent::BidiRequestId = connect::decode_unary(&body)?;
run_sse::stream(&registry, &request.request_id).await
}
async fn bidi_append_handler(
State(registry): State<RunRegistry>,
body: Bytes,
) -> Result<Response<axum::body::Body>> {
let request: ai::BidiAppendRequest = connect::decode_unary(&body)?;
bidi_append::append(&registry, request).await?;
let mut response = Response::new(axum::body::Body::empty());
*response.status_mut() = StatusCode::OK;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("application/proto"),
);
Ok(response)
}
+547
View File
@@ -0,0 +1,547 @@
use crate::{
cursor::{
proto::agent::v1 as pb,
tool_result::{self, ToolCompletion},
},
model::{ToolCall, Usage},
provider::ResponseEvent,
};
use serde_json::Value;
use std::time::Duration;
use crate::{Error, Result};
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 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 tool_started(call: &ToolCall) -> Result<pb::AgentServerMessage> {
Ok(server_interaction(
pb::interaction_update::Message::ToolCallStarted(pb::ToolCallStartedUpdate {
call_id: call.call_id.clone(),
tool_call: Some(render_tool_call(call, false)?),
model_call_id: call.model_call_id.clone(),
}),
))
}
pub fn tool_completed(call: &ToolCall, completion: &ToolCompletion) -> pb::AgentServerMessage {
server_interaction(pb::interaction_update::Message::ToolCallCompleted(
pb::ToolCallCompletedUpdate {
call_id: call.call_id.clone(),
tool_call: Some(completion.tool_call().clone()),
model_call_id: call.model_call_id.clone(),
},
))
}
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()),
"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()),
"readlints" => Tool::ReadLintsToolCall(pb::ReadLintsToolCall::default()),
"callmcptool" => Tool::McpToolCall(pb::McpToolCall::default()),
"createplan" => Tool::CreatePlanToolCall(pb::CreatePlanToolCall::default()),
"websearch" => Tool::WebSearchToolCall(pb::WebSearchToolCall::default()),
"task" => Tool::TaskToolCall(pb::TaskToolCall::default()),
"fetchmcpresource" => Tool::ReadMcpResourceToolCall(pb::ReadMcpResourceToolCall::default()),
"askquestion" => Tool::AskQuestionToolCall(pb::AskQuestionToolCall::default()),
"webfetch" => Tool::WebFetchToolCall(pb::WebFetchToolCall::default()),
"switchmode" => Tool::SwitchModeToolCall(pb::SwitchModeToolCall::default()),
"generateimage" => Tool::GenerateImageToolCall(pb::GenerateImageToolCall::default()),
"communicateupdate" => {
Tool::CommunicateUpdateToolCall(pb::CommunicateUpdateToolCall::default())
}
"writeshellstdin" => Tool::WriteShellStdinToolCall(pb::WriteShellStdinToolCall::default()),
_ => return Err(Error::Protocol(format!("unsupported tool: {name}"))),
};
Ok(pb::ToolCall {
hook_additional_contexts: Vec::new(),
tool_call_id: Some(call_id.into()),
started_at_ms: None,
completed_at_ms: None,
tool: Some(tool),
})
}
pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall> {
let mut output = tool_placeholder(&call.name, &call.call_id)?;
let timestamp = now_ms();
output.started_at_ms = Some(timestamp);
if completed {
output.completed_at_ms = Some(timestamp);
}
let string = |name: &str| {
call.arguments
.get(name)
.and_then(Value::as_str)
.unwrap_or_default()
.to_string()
};
let optional = |name: &str| {
call.arguments
.get(name)
.and_then(Value::as_str)
.map(str::to_string)
};
match output.tool.as_mut() {
Some(pb::tool_call::Tool::ShellToolCall(tool)) => {
tool.args = Some(pb::ShellArgs {
command: string("command"),
working_directory: optional("working_directory").unwrap_or_default(),
tool_call_id: call.call_id.clone(),
..Default::default()
})
}
Some(pb::tool_call::Tool::DeleteToolCall(tool)) => {
tool.args = Some(pb::DeleteArgs {
path: string("path"),
tool_call_id: call.call_id.clone(),
})
}
Some(pb::tool_call::Tool::GlobToolCall(tool)) => {
tool.args = Some(pb::GlobToolArgs {
target_directory: optional("target_directory"),
glob_pattern: string("glob_pattern"),
})
}
Some(pb::tool_call::Tool::GrepToolCall(tool)) => {
tool.args = Some(pb::GrepArgs {
pattern: string("pattern"),
path: optional("path"),
glob: optional("glob"),
output_mode: optional("output_mode"),
tool_call_id: call.call_id.clone(),
..Default::default()
})
}
Some(pb::tool_call::Tool::ReadToolCall(tool)) => {
tool.args = Some(pb::ReadToolArgs {
path: string("path"),
offset: call
.arguments
.get("offset")
.and_then(Value::as_i64)
.map(|value| value as i32),
limit: call
.arguments
.get("limit")
.and_then(Value::as_i64)
.map(|value| value as i32),
include_line_numbers: call
.arguments
.get("include_line_numbers")
.and_then(Value::as_bool),
})
}
Some(pb::tool_call::Tool::UpdateTodosToolCall(tool)) => {
tool.args = Some(pb::UpdateTodosArgs {
todos: tool_result::todo_items(&call.arguments),
merge: call
.arguments
.get("merge")
.and_then(Value::as_bool)
.unwrap_or(false),
})
}
Some(pb::tool_call::Tool::EditToolCall(tool)) => {
let stream_content = if normalized(&call.name) == "write" {
optional("contents").unwrap_or_default()
} else {
format!("{}\n---\n{}", string("old_string"), string("new_string"))
};
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()
})
}
Some(pb::tool_call::Tool::ReadLintsToolCall(tool)) => {
tool.args = Some(pb::ReadLintsToolArgs {
paths: call
.arguments
.get("paths")
.and_then(Value::as_array)
.into_iter()
.flatten()
.filter_map(Value::as_str)
.map(str::to_string)
.collect(),
})
}
Some(pb::tool_call::Tool::McpToolCall(tool)) => {
tool.args = Some(pb::McpArgs {
name: optional("toolName").unwrap_or_default(),
args: call
.arguments
.get("arguments")
.and_then(Value::as_object)
.map(super::exec::json_object_to_prost)
.unwrap_or_default(),
tool_call_id: call.call_id.clone(),
tool_name: optional("toolName").unwrap_or_default(),
server_identifier: string("server"),
..Default::default()
})
}
Some(pb::tool_call::Tool::CreatePlanToolCall(tool)) => {
tool.args = Some(pb::CreatePlanArgs {
plan: string("plan"),
todos: tool_result::todo_items(&call.arguments),
overview: string("overview"),
name: string("name"),
is_project: false,
phases: Vec::new(),
})
}
Some(pb::tool_call::Tool::WebSearchToolCall(tool)) => {
tool.args = Some(pb::WebSearchArgs {
search_term: string("search_term"),
tool_call_id: call.call_id.clone(),
})
}
Some(pb::tool_call::Tool::TaskToolCall(tool)) => {
tool.args = Some(pb::TaskArgs {
description: string("description"),
prompt: string("prompt"),
subagent_type: Some(subagent_type(&string("subagent_type"))),
model: optional("model"),
resume: optional("resume"),
agent_id: None,
attachments: Vec::new(),
mode: 0,
responding_to_message_ids: Vec::new(),
environment: 0,
machine: None,
})
}
Some(pb::tool_call::Tool::ReadMcpResourceToolCall(tool)) => {
tool.args = Some(pb::ReadMcpResourceExecArgs {
server: string("server"),
uri: string("uri"),
download_path: optional("downloadPath"),
tool_call_id: call.call_id.clone(),
smart_mode_approval: None,
})
}
Some(pb::tool_call::Tool::WebFetchToolCall(tool)) => {
tool.args = Some(pb::WebFetchArgs {
url: string("url"),
tool_call_id: call.call_id.clone(),
})
}
Some(pb::tool_call::Tool::SwitchModeToolCall(tool)) => {
tool.args = Some(pb::SwitchModeArgs {
target_mode_id: string("target_mode_id"),
explanation: optional("explanation"),
tool_call_id: call.call_id.clone(),
})
}
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(),
aspect_ratio: optional("aspect_ratio"),
})
}
Some(pb::tool_call::Tool::CommunicateUpdateToolCall(tool)) => {
tool.args = Some(pb::CommunicateUpdateArgs {
current_step: optional("current_step"),
final_summary: optional("final_summary"),
completed_subtitle: optional("completed_subtitle"),
})
}
Some(pb::tool_call::Tool::WriteShellStdinToolCall(tool)) => {
tool.args = Some(pb::WriteShellStdinArgs {
shell_id: call
.arguments
.get("shell_id")
.and_then(Value::as_u64)
.unwrap_or_default() as u32,
chars: string("chars"),
})
}
_ => {}
}
Ok(output)
}
fn subagent_type(name: &str) -> pb::SubagentType {
use pb::subagent_type::Type;
let r#type = match name.to_ascii_lowercase().as_str() {
"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 {}),
"computer-use" | "computeruse" => Type::ComputerUse(pb::SubagentTypeComputerUse {}),
"" => Type::Unspecified(pb::SubagentTypeUnspecified {}),
custom => Type::Custom(pb::SubagentTypeCustom {
name: custom.into(),
}),
};
pb::SubagentType {
r#type: Some(r#type),
}
}
fn normalized(value: &str) -> String {
value
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
fn now_ms() -> u64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as u64
}
+12
View File
@@ -0,0 +1,12 @@
pub mod bidi_append;
pub mod blob_sync;
pub mod checkpoint;
pub mod connect;
pub mod exec;
pub mod handlers;
pub mod interaction;
pub mod pending;
pub mod proto;
pub mod run_sse;
pub mod tool_result;
pub mod tools;
+139
View File
@@ -0,0 +1,139 @@
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
}
+71
View File
@@ -0,0 +1,71 @@
pub mod agent {
#[allow(clippy::large_enum_variant)]
pub mod v1 {
include!(concat!(env!("OUT_DIR"), "/agent.v1.rs"));
}
}
pub mod aiserver {
pub mod v1 {
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct BidiRequestId {
#[prost(string, tag = "1")]
pub request_id: String,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct BidiAppendRequest {
#[prost(string, tag = "1")]
pub data: String,
#[prost(message, optional, tag = "2")]
pub request_id: Option<BidiRequestId>,
#[prost(int64, tag = "3")]
pub append_seqno: i64,
#[prost(bytes = "vec", tag = "4")]
pub data_binary: Vec<u8>,
}
#[derive(Clone, Copy, PartialEq, ::prost::Message)]
pub struct BidiAppendResponse {}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct CustomErrorDetails {
#[prost(string, tag = "1")]
pub title: String,
#[prost(string, tag = "2")]
pub detail: String,
#[prost(bool, optional, tag = "3")]
pub allow_command_links_potentially_unsafe_please_only_use_for_handwritten_trusted_markdown:
Option<bool>,
#[prost(bool, optional, tag = "4")]
pub is_retryable: Option<bool>,
#[prost(bool, optional, tag = "5")]
pub show_request_id: Option<bool>,
#[prost(bool, optional, tag = "6")]
pub should_show_immediate_error: Option<bool>,
}
#[derive(Clone, PartialEq, ::prost::Message)]
pub struct ErrorDetails {
#[prost(enumeration = "error_details::Error", tag = "1")]
pub error: i32,
#[prost(message, optional, tag = "2")]
pub details: Option<CustomErrorDetails>,
#[prost(bool, optional, tag = "3")]
pub is_expected: Option<bool>,
}
pub mod error_details {
#[derive(
Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord, ::prost::Enumeration,
)]
#[repr(i32)]
pub enum Error {
Unspecified = 0,
CustomMessage = 29,
ProviderError = 57,
Internal = 59,
}
}
}
}
+28
View File
@@ -0,0 +1,28 @@
use axum::{
body::Body,
http::{header, HeaderValue, Response, StatusCode},
};
use bytes::Bytes;
use tokio_stream::wrappers::UnboundedReceiverStream;
use tokio_stream::StreamExt;
use crate::{run::RunRegistry, Result};
pub async fn stream(registry: &RunRegistry, 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>);
let mut response = Response::new(Body::from_stream(body_stream));
*response.status_mut() = StatusCode::OK;
response.headers_mut().insert(
header::CONTENT_TYPE,
HeaderValue::from_static("text/event-stream"),
);
response
.headers_mut()
.insert(header::CACHE_CONTROL, HeaderValue::from_static("no-cache"));
response
.headers_mut()
.insert("connect-protocol-version", HeaderValue::from_static("1"));
Ok(response)
}
File diff suppressed because it is too large Load Diff
+318
View File
@@ -0,0 +1,318 @@
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(_))
));
}
}
+62
View File
@@ -0,0 +1,62 @@
use axum::{
http::StatusCode,
response::{IntoResponse, Response},
Json,
};
pub type Result<T, E = Error> = std::result::Result<T, E>;
#[derive(Debug, thiserror::Error)]
pub enum Error {
#[error("configuration error: {0}")]
Config(String),
#[error("protocol error: {0}")]
Protocol(String),
#[error("provider error: {0}")]
Provider(String),
#[error("run was cancelled")]
Cancelled,
#[error("run not found: {0}")]
RunNotFound(String),
#[error("database error: {0}")]
Database(#[from] sqlx::Error),
#[error("database migration error: {0}")]
Migration(#[from] sqlx::migrate::MigrateError),
#[error("http error: {0}")]
Http(#[from] reqwest::Error),
#[error("protobuf decode error: {0}")]
Decode(#[from] prost::DecodeError),
#[error("protobuf encode error: {0}")]
Encode(#[from] prost::EncodeError),
#[error("json error: {0}")]
Json(#[from] serde_json::Error),
#[error("io error: {0}")]
Io(#[from] std::io::Error),
}
impl IntoResponse for Error {
fn into_response(self) -> Response {
let status = match self {
Self::Protocol(_) | Self::Decode(_) | Self::Json(_) => StatusCode::BAD_REQUEST,
Self::RunNotFound(_) => StatusCode::NOT_FOUND,
Self::Config(_) => StatusCode::INTERNAL_SERVER_ERROR,
Self::Provider(_) | Self::Http(_) => StatusCode::BAD_GATEWAY,
Self::Cancelled => StatusCode::CONFLICT,
Self::Database(_) | Self::Migration(_) | Self::Encode(_) | Self::Io(_) => {
StatusCode::INTERNAL_SERVER_ERROR
}
};
let code = match status {
StatusCode::BAD_REQUEST => "invalid_argument",
StatusCode::NOT_FOUND => "not_found",
StatusCode::CONFLICT => "aborted",
StatusCode::BAD_GATEWAY => "unavailable",
_ => "internal",
};
(
status,
Json(serde_json::json!({ "code": code, "message": self.to_string() })),
)
.into_response()
}
}
+13
View File
@@ -0,0 +1,13 @@
pub mod app;
pub mod config;
pub mod cursor;
pub mod error;
pub mod model;
pub mod prompting;
pub mod provider;
pub mod run;
pub mod store;
pub use app::App;
pub use config::Config;
pub use error::{Error, Result};
+16
View File
@@ -0,0 +1,16 @@
use cursor_server::{App, Config};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[tokio::main]
async fn main() -> Result<(), Box<dyn std::error::Error>> {
tracing_subscriber::registry()
.with(
tracing_subscriber::EnvFilter::try_from_default_env()
.unwrap_or_else(|_| "cursor_server=info".into()),
)
.with(tracing_subscriber::fmt::layer())
.init();
App::new(Config::from_env()?).await?.serve().await?;
Ok(())
}
+29
View File
@@ -0,0 +1,29 @@
use serde::{Deserialize, Serialize};
use super::Usage;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Conversation {
pub conversation_id: String,
pub revision: i64,
pub head_blob_id: Option<String>,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum TurnStatus {
Running,
Completed,
Interrupted,
Failed,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct Turn {
pub request_id: String,
pub run_id: String,
pub conversation_id: String,
pub revision: i64,
pub status: TurnStatus,
pub usage: Usage,
}
+83
View File
@@ -0,0 +1,83 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Role {
System,
User,
Assistant,
Tool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "snake_case")]
pub enum Origin {
Prompt,
User,
Runtime,
Assistant,
Tool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ToolCallContent {
pub index: usize,
pub call_id: String,
pub name: String,
pub arguments: Value,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ToolResultContent {
pub call_id: String,
pub name: String,
pub output: Value,
pub is_error: bool,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(tag = "kind", rename_all = "snake_case")]
pub enum MessageContent {
Text {
text: String,
},
Assistant {
text: String,
thinking: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
model_call_id: Option<String>,
tool_calls: Vec<ToolCallContent>,
},
ToolResult(ToolResultContent),
Json {
value: Value,
},
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct CanonicalMessage {
pub message_id: String,
pub role: Role,
pub origin: Origin,
pub content: MessageContent,
#[serde(skip_serializing_if = "Option::is_none")]
pub runtime_event_id: Option<String>,
}
impl CanonicalMessage {
pub fn text(
message_id: impl Into<String>,
role: Role,
origin: Origin,
text: impl Into<String>,
) -> Self {
Self {
message_id: message_id.into(),
role,
origin,
content: MessageContent::Text { text: text.into() },
runtime_event_id: None,
}
}
}
+11
View File
@@ -0,0 +1,11 @@
mod conversation;
mod message;
mod runtime_tag;
mod tool;
mod usage;
pub use conversation::*;
pub use message::*;
pub use runtime_tag::*;
pub use tool::*;
pub use usage::*;
+21
View File
@@ -0,0 +1,21 @@
use serde::{Deserialize, Serialize};
use super::{CanonicalMessage, MessageContent, Origin, Role};
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq, Eq)]
pub struct RuntimeEvent {
pub event_id: String,
pub text: String,
}
impl RuntimeEvent {
pub fn into_message(self) -> CanonicalMessage {
CanonicalMessage {
message_id: format!("runtime:{}", self.event_id),
role: Role::User,
origin: Origin::Runtime,
content: MessageContent::Text { text: self.text },
runtime_event_id: Some(self.event_id),
}
}
}
+19
View File
@@ -0,0 +1,19 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ToolCall {
pub index: usize,
pub call_id: String,
pub model_call_id: String,
pub name: String,
pub arguments_text: String,
pub arguments: Value,
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ToolResult {
pub call_id: String,
pub output: Value,
pub is_error: bool,
}
+22
View File
@@ -0,0 +1,22 @@
use std::ops::AddAssign;
use serde::{Deserialize, Serialize};
#[derive(Clone, Copy, Debug, Default, Serialize, Deserialize, PartialEq, Eq)]
pub struct Usage {
pub input_tokens: u64,
pub output_tokens: u64,
pub cache_read_tokens: u64,
pub cache_write_tokens: u64,
pub reasoning_tokens: u64,
}
impl AddAssign for Usage {
fn add_assign(&mut self, rhs: Self) {
self.input_tokens += rhs.input_tokens;
self.output_tokens += rhs.output_tokens;
self.cache_read_tokens += rhs.cache_read_tokens;
self.cache_write_tokens += rhs.cache_write_tokens;
self.reasoning_tokens += rhs.reasoning_tokens;
}
}
+175
View File
@@ -0,0 +1,175 @@
use std::{collections::HashMap, path::Path};
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::{Error, Result};
static EMBEDDED_PROMPTS: include_dir::Dir<'_> =
include_dir::include_dir!("$CARGO_MANIFEST_DIR/../prompt");
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Mode {
Agent,
Ask,
Plan,
Debug,
Multitask,
Subagent,
Compaction,
Commit,
}
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),
"commit" => Ok(Self::Commit),
other => Err(Error::Config(format!("unknown prompt mode: {other}"))),
}
}
fn directory(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",
Self::Commit => "commit",
}
}
}
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
pub struct ToolDefinition {
pub name: String,
pub description: String,
pub input_schema: Value,
}
#[derive(Clone, Debug)]
pub struct ModeAssets {
pub prompt: String,
pub tools: Vec<ToolDefinition>,
pub reminders: HashMap<String, String>,
}
#[derive(Clone, Debug)]
pub struct PromptAssets {
modes: HashMap<Mode, ModeAssets>,
}
impl PromptAssets {
pub fn load(root: &Path) -> Result<Self> {
Self::read(|directory, filename| {
let path = root.join(directory).join(filename);
path.exists()
.then(|| std::fs::read_to_string(path).map_err(Error::from))
.transpose()
})
}
pub fn embedded() -> Result<Self> {
Self::read(|directory, filename| {
EMBEDDED_PROMPTS
.get_file(format!("{directory}/{filename}"))
.map(|file| {
file.contents_utf8().map(str::to_string).ok_or_else(|| {
Error::Config(format!("prompt asset is not UTF-8: {directory}/{filename}"))
})
})
.transpose()
})
}
fn read(mut asset: impl FnMut(&str, &str) -> Result<Option<String>>) -> Result<Self> {
let mut modes = HashMap::new();
for mode in [
Mode::Agent,
Mode::Ask,
Mode::Plan,
Mode::Debug,
Mode::Multitask,
Mode::Subagent,
Mode::Compaction,
Mode::Commit,
] {
let prompt = asset(mode.directory(), "prompt.md")?
.ok_or_else(|| Error::Config(format!("missing prompt for {:?}", mode)))?;
if prompt.trim().is_empty() {
return Err(Error::Config(format!("empty prompt for {:?}", mode)));
}
let tools = if let Some(contents) = asset(mode.directory(), "tools.json")? {
parse_tools(&contents)?
} else {
Vec::new()
};
let mut reminders = HashMap::new();
for reminder in [
"system_reminder.txt",
"system_reminder_initial.txt",
"system_reminder_continuing.txt",
] {
if let Some(contents) = asset(mode.directory(), reminder)? {
reminders.insert(reminder.trim_end_matches(".txt").into(), contents);
}
}
modes.insert(
mode,
ModeAssets {
prompt,
tools,
reminders,
},
);
}
Ok(Self { modes })
}
pub fn mode(&self, mode: Mode) -> &ModeAssets {
self.modes
.get(&mode)
.expect("all modes validated at startup")
}
}
fn parse_tools(json: &str) -> Result<Vec<ToolDefinition>> {
let value: Value = serde_json::from_str(json)?;
let array = value
.as_array()
.ok_or_else(|| Error::Config("tools.json must be an array".into()))?;
array
.iter()
.map(|tool| {
let source = tool
.get("function")
.ok_or_else(|| Error::Config("tool is missing function".into()))?;
Ok(ToolDefinition {
name: source
.get("name")
.and_then(Value::as_str)
.ok_or_else(|| Error::Config("tool is missing name".into()))?
.into(),
description: source
.get("description")
.and_then(Value::as_str)
.ok_or_else(|| Error::Config("tool is missing description".into()))?
.to_string(),
input_schema: source
.get("parameters")
.cloned()
.ok_or_else(|| Error::Config("tool is missing parameters".into()))?,
})
})
.collect()
}
+79
View File
@@ -0,0 +1,79 @@
use serde_json::Value;
use crate::{model::CanonicalMessage, Result};
use super::{project_messages, Mode, PromptAssets, ToolDefinition};
#[derive(Clone, Debug, PartialEq)]
pub struct ProviderMessage {
pub role: String,
pub content: Value,
pub thinking: Option<String>,
pub tool_call_id: Option<String>,
pub tool_calls: Option<Vec<Value>>,
}
#[derive(Clone, Debug)]
pub struct ModelRequest {
pub model: String,
pub model_call_id: String,
pub messages: Vec<ProviderMessage>,
pub tools: Vec<ToolDefinition>,
}
#[derive(Clone)]
pub struct PromptCompiler {
assets: PromptAssets,
}
impl PromptCompiler {
pub fn new(assets: PromptAssets) -> Self {
Self { assets }
}
pub fn compile(
&self,
mode: Mode,
model: impl Into<String>,
model_call_id: impl Into<String>,
messages: &[CanonicalMessage],
) -> Result<ModelRequest> {
self.compile_with_dynamic_tools(mode, model, model_call_id, messages, &[])
}
pub fn compile_with_dynamic_tools(
&self,
mode: Mode,
model: impl Into<String>,
model_call_id: impl Into<String>,
messages: &[CanonicalMessage],
dynamic_tools: &[ToolDefinition],
) -> Result<ModelRequest> {
let assets = self.assets.mode(mode);
let mut projected = Vec::with_capacity(messages.len() + 1);
projected.push(ProviderMessage {
role: "system".into(),
content: Value::String(assets.prompt.clone()),
thinking: None,
tool_call_id: None,
tool_calls: None,
});
projected.extend(project_messages(messages)?);
let mut tools = assets.tools.clone();
let mut dynamic_tools = dynamic_tools.to_vec();
dynamic_tools.sort_by(|left, right| left.name.cmp(&right.name));
for tool in dynamic_tools {
if let Some(existing) = tools.iter_mut().find(|existing| existing.name == tool.name) {
*existing = tool;
} else {
tools.push(tool);
}
}
Ok(ModelRequest {
model: model.into(),
model_call_id: model_call_id.into(),
messages: projected,
tools,
})
}
}
@@ -0,0 +1,48 @@
use serde::{Deserialize, Serialize};
use serde_json::Value;
use crate::model::{CanonicalMessage, MessageContent};
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct DerivedState {
pub todos: Option<Value>,
pub plan: Option<Value>,
}
pub fn fold_derived_state(messages: &[CanonicalMessage]) -> DerivedState {
let mut state = DerivedState::default();
let mut calls = std::collections::HashMap::<String, (String, Value)>::new();
for message in messages {
match &message.content {
MessageContent::Assistant { tool_calls, .. } => {
for call in tool_calls {
calls.insert(
call.call_id.clone(),
(call.name.clone(), call.arguments.clone()),
);
}
}
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()));
match normalize(&name).as_str() {
"todowrite" | "updatetodos" => state.todos = Some(input),
"createplan" | "updateplan" | "writeplan" => state.plan = Some(input),
_ => {}
}
}
_ => {}
}
}
state
}
fn normalize(value: &str) -> String {
value
.chars()
.filter(|character| character.is_ascii_alphanumeric())
.flat_map(char::to_lowercase)
.collect()
}
+9
View File
@@ -0,0 +1,9 @@
mod assets;
mod compiler;
mod derived_state;
mod projector;
pub use assets::*;
pub use compiler::*;
pub use derived_state::*;
pub use projector::*;
+189
View File
@@ -0,0 +1,189 @@
use std::collections::HashMap;
use serde_json::{json, Value};
use crate::{
model::{CanonicalMessage, MessageContent, Role, ToolCallContent, ToolResultContent},
Error, Result,
};
use super::ProviderMessage;
pub fn project_messages(messages: &[CanonicalMessage]) -> Result<Vec<ProviderMessage>> {
let mut projected = Vec::new();
let mut index = 0;
while index < messages.len() {
if let Some((group, next_index)) = project_tool_group(messages, index)? {
projected.extend(group);
index = next_index;
} else {
projected.push(project_message(&messages[index])?);
index += 1;
}
}
Ok(projected)
}
fn project_tool_group(
messages: &[CanonicalMessage],
start: usize,
) -> Result<Option<(Vec<ProviderMessage>, usize)>> {
let MessageContent::Assistant {
model_call_id: Some(group_id),
tool_calls,
..
} = &messages[start].content
else {
return Ok(None);
};
if tool_calls.is_empty() {
return Ok(None);
}
let mut cursor = start;
let mut text = String::new();
let mut thinking = String::new();
let mut calls = Vec::<ToolCallContent>::new();
let mut results = HashMap::<String, ToolResultContent>::new();
while cursor < messages.len() {
let MessageContent::Assistant {
text: part_text,
thinking: part_thinking,
model_call_id: Some(candidate_group),
tool_calls: part_calls,
} = &messages[cursor].content
else {
break;
};
if candidate_group != group_id || part_calls.is_empty() {
break;
}
text.push_str(part_text);
thinking.push_str(part_thinking);
calls.extend(part_calls.iter().cloned());
cursor += 1;
while cursor < messages.len() {
let MessageContent::ToolResult(result) = &messages[cursor].content else {
break;
};
if !calls.iter().any(|call| call.call_id == result.call_id) {
break;
}
if results
.insert(result.call_id.clone(), result.clone())
.is_some()
{
return Err(Error::Protocol(format!(
"duplicate tool result call_id: {}",
result.call_id
)));
}
cursor += 1;
}
}
calls.sort_by_key(|call| call.index);
for call in &calls {
if !results.contains_key(&call.call_id) {
return Err(Error::Protocol(format!(
"assistant tool call has no result call_id: {}",
call.call_id
)));
}
}
let mut output = Vec::with_capacity(calls.len() + 1);
output.push(ProviderMessage {
role: "assistant".into(),
content: Value::String(text),
thinking: (!thinking.is_empty()).then_some(thinking),
tool_call_id: None,
tool_calls: Some(
calls
.iter()
.map(project_tool_call)
.collect::<Result<Vec<_>>>()?,
),
});
for call in &calls {
output.push(project_tool_result(&results[&call.call_id])?);
}
Ok(Some((output, cursor)))
}
fn project_message(message: &CanonicalMessage) -> Result<ProviderMessage> {
match &message.content {
MessageContent::Assistant {
text,
thinking,
tool_calls,
..
} => Ok(ProviderMessage {
role: "assistant".into(),
content: Value::String(text.clone()),
thinking: (!thinking.is_empty()).then(|| thinking.clone()),
tool_call_id: None,
tool_calls: if tool_calls.is_empty() {
None
} else {
Some(
tool_calls
.iter()
.map(project_tool_call)
.collect::<Result<Vec<_>>>()?,
)
},
}),
MessageContent::ToolResult(result) => project_tool_result(result),
MessageContent::Text { text } => Ok(ProviderMessage {
role: role_name(&message.role).into(),
content: Value::String(text.clone()),
thinking: None,
tool_call_id: None,
tool_calls: None,
}),
MessageContent::Json { value } => Ok(ProviderMessage {
role: role_name(&message.role).into(),
content: value.clone(),
thinking: None,
tool_call_id: None,
tool_calls: None,
}),
}
}
fn project_tool_call(call: &ToolCallContent) -> Result<Value> {
Ok(json!({
"id": call.call_id,
"type": "function",
"function": {
"name": call.name,
"arguments": serde_json::to_string(&call.arguments)?
}
}))
}
fn project_tool_result(result: &ToolResultContent) -> Result<ProviderMessage> {
let content = match result.output.as_str() {
Some(text) => text.to_string(),
None => serde_json::to_string(&result.output)?,
};
Ok(ProviderMessage {
role: "tool".into(),
content: Value::String(content),
thinking: None,
tool_call_id: Some(result.call_id.clone()),
tool_calls: None,
})
}
fn role_name(role: &Role) -> &'static str {
match role {
Role::System => "system",
Role::User => "user",
Role::Assistant => "assistant",
Role::Tool => "tool",
}
}
+214
View File
@@ -0,0 +1,214 @@
use async_stream::try_stream;
use eventsource_stream::Eventsource;
use futures_util::StreamExt;
use serde_json::{json, Value};
use crate::{config::ProviderConfig, model::Usage, prompting::ModelRequest, Error, Result};
use super::{FinishReason, Provider, ProviderStream, ResponseEvent};
pub struct AnthropicProvider {
client: reqwest::Client,
config: ProviderConfig,
}
impl AnthropicProvider {
pub fn new(client: reqwest::Client, config: ProviderConfig) -> Self {
Self { client, config }
}
}
impl Provider for AnthropicProvider {
fn stream(
&self,
request: ModelRequest,
cancellation: tokio_util::sync::CancellationToken,
) -> ProviderStream {
let client = self.client.clone();
let config = self.config.clone();
Box::pin(try_stream! {
let mut system = String::new();
let messages = anthropic_messages(&request.messages, &mut system)?;
let body = json!({
"model": request.model, "system": system, "messages": messages,
"max_tokens": 32768, "stream": true,
"tools": request.tools.iter().map(|tool| json!({
"name": tool.name, "description": tool.description, "input_schema": tool.input_schema
})).collect::<Vec<_>>()
});
let response = client.post(format!("{}/messages", config.base_url))
.header("x-api-key", &config.api_key).header("anthropic-version", "2023-06-01")
.json(&body).send().await?;
if !response.status().is_success() {
let status = response.status(); let text = response.text().await?;
Err(Error::Provider(format!("Anthropic {status}: {text}")))?;
return;
}
yield ResponseEvent::Start { model_call_id: request.model_call_id };
let mut source = response.bytes_stream().eventsource();
let mut block_types = std::collections::HashMap::<usize, String>::new();
let mut finish = FinishReason::Stop;
while let Some(event) = tokio::select! {
_ = cancellation.cancelled() => { yield ResponseEvent::Done(FinishReason::Aborted); return; }
event = source.next() => event,
} {
let event = event.map_err(|error| Error::Provider(format!("Anthropic SSE: {error}")))?;
let value: Value = serde_json::from_str(&event.data)?;
match event.event.as_str() {
"message_start" => if let Some(usage) = value.pointer("/message/usage") { yield ResponseEvent::Usage(anthropic_usage(usage)); },
"content_block_start" => {
let index = required_u64(&value, "index")? as usize;
let block = value.get("content_block").unwrap_or(&Value::Null);
let kind = required_string(block, "type")?;
block_types.insert(index, kind.into());
match kind {
"text" => yield ResponseEvent::TextStart,
"thinking" => yield ResponseEvent::ThinkingStart,
"tool_use" => {
finish = FinishReason::ToolUse;
yield ResponseEvent::ToolCallStart {
index,
call_id: required_string(block, "id")?.into(),
name: required_string(block, "name")?.into(),
};
}
_ => {}
}
}
"content_block_delta" => {
let index = required_u64(&value, "index")? as usize;
let delta = value.get("delta").unwrap_or(&Value::Null);
match required_string(delta, "type")? {
"text_delta" => if let Some(text) = delta.get("text").and_then(Value::as_str) { yield ResponseEvent::TextDelta(text.into()); },
"thinking_delta" => if let Some(text) = delta.get("thinking").and_then(Value::as_str) { yield ResponseEvent::ThinkingDelta(text.into()); },
"input_json_delta" => if let Some(text) = delta.get("partial_json").and_then(Value::as_str) { yield ResponseEvent::ToolCallArgumentsDelta { index, delta: text.into() }; },
_ => {}
}
}
"content_block_stop" => {
let index = required_u64(&value, "index")? as usize;
match block_types.remove(&index).as_deref() {
Some("text") => yield ResponseEvent::TextEnd,
Some("thinking") => yield ResponseEvent::ThinkingEnd,
Some("tool_use") => yield ResponseEvent::ToolCallEnd { index },
_ => {}
}
}
"message_delta" => {
if let Some(usage) = value.get("usage") { yield ResponseEvent::Usage(anthropic_usage(usage)); }
finish = match value.pointer("/delta/stop_reason").and_then(Value::as_str) {
Some("tool_use") => FinishReason::ToolUse, Some("max_tokens") => FinishReason::Length,
Some("end_turn") | Some("stop_sequence") | None => finish,
Some(other) => Err(Error::Provider(format!("unknown Anthropic stop_reason: {other}")))?,
};
}
"error" => Err(Error::Provider(format!("Anthropic stream error: {}", event.data)))?,
_ => {}
}
}
yield ResponseEvent::Done(finish);
})
}
}
fn anthropic_messages(
messages: &[crate::prompting::ProviderMessage],
system: &mut String,
) -> Result<Vec<Value>> {
let mut output = Vec::new();
for message in messages {
if message.role == "system" {
if !system.is_empty() {
system.push_str("\n\n");
}
system.push_str(content_text(&message.content)?);
continue;
}
if message.role == "tool" {
push_anthropic(
&mut output,
"user",
vec![json!({
"type":"tool_result", "tool_use_id":message.tool_call_id.as_deref().ok_or_else(|| Error::Protocol("tool message is missing call_id".into()))?,
"content":content_text(&message.content)?
})],
);
continue;
}
let mut content = Vec::new();
let text = content_text(&message.content)?;
if !text.is_empty() {
content.push(json!({"type":"text", "text":text}));
}
for call in message.tool_calls.iter().flatten() {
let arguments = call
.pointer("/function/arguments")
.and_then(Value::as_str)
.ok_or_else(|| Error::Protocol("tool call is missing function.arguments".into()))?;
content.push(json!({
"type":"tool_use", "id":call.get("id").and_then(Value::as_str).ok_or_else(|| Error::Protocol("tool call is missing id".into()))?,
"name":call.pointer("/function/name").and_then(Value::as_str).ok_or_else(|| Error::Protocol("tool call is missing function.name".into()))?,
"input":serde_json::from_str::<Value>(arguments)?
}));
}
if !content.is_empty() {
push_anthropic(&mut output, &message.role, content);
}
}
Ok(output)
}
fn push_anthropic(output: &mut Vec<Value>, role: &str, mut content: Vec<Value>) {
if let Some(last) = output
.last_mut()
.filter(|last| last.get("role").and_then(Value::as_str) == Some(role))
{
if let Some(existing) = last.get_mut("content").and_then(Value::as_array_mut) {
existing.append(&mut content);
return;
}
}
output.push(json!({"role":role, "content":content}));
}
fn content_text(value: &Value) -> Result<&str> {
value
.as_str()
.ok_or_else(|| Error::Protocol("provider message content must be a string".into()))
}
fn required_string<'a>(value: &'a Value, name: &str) -> Result<&'a str> {
value
.get(name)
.and_then(Value::as_str)
.ok_or_else(|| Error::Provider(format!("Anthropic event is missing {name}")))
}
fn required_u64(value: &Value, name: &str) -> Result<u64> {
value
.get(name)
.and_then(Value::as_u64)
.ok_or_else(|| Error::Provider(format!("Anthropic event is missing {name}")))
}
fn anthropic_usage(value: &Value) -> Usage {
Usage {
input_tokens: value
.get("input_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
output_tokens: value
.get("output_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
cache_read_tokens: value
.get("cache_read_input_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
cache_write_tokens: value
.get("cache_creation_input_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
reasoning_tokens: 0,
}
}
+37
View File
@@ -0,0 +1,37 @@
use crate::model::Usage;
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum FinishReason {
Stop,
Length,
ToolUse,
Error,
Aborted,
}
#[derive(Clone, Debug, PartialEq)]
pub enum ResponseEvent {
Start {
model_call_id: String,
},
TextStart,
TextDelta(String),
TextEnd,
ThinkingStart,
ThinkingDelta(String),
ThinkingEnd,
ToolCallStart {
index: usize,
call_id: String,
name: String,
},
ToolCallArgumentsDelta {
index: usize,
delta: String,
},
ToolCallEnd {
index: usize,
},
Usage(Usage),
Done(FinishReason),
}
+39
View File
@@ -0,0 +1,39 @@
mod anthropic;
mod event;
mod openai_chat;
mod openai_responses;
use std::{pin::Pin, sync::Arc};
use futures_util::Stream;
use tokio_util::sync::CancellationToken;
use crate::{
config::{ProviderConfig, ProviderKind},
prompting::ModelRequest,
Result,
};
pub use anthropic::AnthropicProvider;
pub use event::*;
pub use openai_chat::OpenAiChatProvider;
pub use openai_responses::OpenAiResponsesProvider;
pub type ProviderStream = Pin<Box<dyn Stream<Item = Result<ResponseEvent>> + Send>>;
pub trait Provider: Send + Sync {
fn stream(&self, request: ModelRequest, cancellation: CancellationToken) -> ProviderStream;
}
pub fn build_provider(config: &ProviderConfig) -> Result<Arc<dyn Provider>> {
let client = reqwest::Client::builder()
.timeout(config.request_timeout)
.build()?;
Ok(match config.kind {
ProviderKind::OpenAiChat => Arc::new(OpenAiChatProvider::new(client, config.clone())),
ProviderKind::OpenAiResponses => {
Arc::new(OpenAiResponsesProvider::new(client, config.clone()))
}
ProviderKind::Anthropic => Arc::new(AnthropicProvider::new(client, config.clone())),
})
}
+196
View File
@@ -0,0 +1,196 @@
use std::collections::{btree_map::Entry, BTreeMap};
use async_stream::try_stream;
use eventsource_stream::Eventsource;
use futures_util::StreamExt;
use serde_json::{json, Map, Value};
use crate::{config::ProviderConfig, model::Usage, prompting::ModelRequest, Error};
use super::{FinishReason, Provider, ProviderStream, ResponseEvent};
pub struct OpenAiChatProvider {
client: reqwest::Client,
config: ProviderConfig,
}
impl OpenAiChatProvider {
pub fn new(client: reqwest::Client, config: ProviderConfig) -> Self {
Self { client, config }
}
}
impl Provider for OpenAiChatProvider {
fn stream(
&self,
request: ModelRequest,
cancellation: tokio_util::sync::CancellationToken,
) -> ProviderStream {
let client = self.client.clone();
let config = self.config.clone();
Box::pin(try_stream! {
let messages = openai_chat_messages(&request.messages);
let body = json!({
"model": request.model,
"messages": messages,
"tools": request.tools.iter().map(|tool| json!({"type":"function","function":{
"name": tool.name, "description": tool.description, "parameters": tool.input_schema
}})).collect::<Vec<_>>(),
"stream": true,
"stream_options": {"include_usage": true}
});
let response = client.post(format!("{}/chat/completions", config.base_url))
.bearer_auth(&config.api_key).json(&body).send().await?;
if !response.status().is_success() {
let status = response.status();
let text = response.text().await?;
Err(Error::Provider(format!("OpenAI Chat {status}: {text}")))?;
return;
}
yield ResponseEvent::Start { model_call_id: request.model_call_id };
let mut source = response.bytes_stream().eventsource();
let mut text_open = false;
let mut thinking_open = false;
let mut tools: BTreeMap<usize, (String, String)> = BTreeMap::new();
let mut finish = None;
loop {
let event = tokio::select! {
_ = cancellation.cancelled() => {
yield ResponseEvent::Done(FinishReason::Aborted);
return;
}
event = source.next() => event,
};
let Some(event) = event else { break };
let event = event.map_err(|error| Error::Provider(format!("OpenAI Chat SSE: {error}")))?;
if event.data == "[DONE]" { break; }
let value: Value = serde_json::from_str(&event.data)?;
if let Some(usage) = value.get("usage").filter(|value| !value.is_null()) {
yield ResponseEvent::Usage(openai_usage(usage));
}
let Some(choice) = value.get("choices").and_then(Value::as_array).and_then(|values| values.first()) else { continue; };
let delta = choice.get("delta").unwrap_or(&Value::Null);
if let Some(reasoning) = delta.get("reasoning_content").and_then(Value::as_str).filter(|text| !text.is_empty()) {
if !thinking_open { thinking_open = true; yield ResponseEvent::ThinkingStart; }
yield ResponseEvent::ThinkingDelta(reasoning.into());
}
if let Some(content) = delta.get("content").and_then(Value::as_str).filter(|text| !text.is_empty()) {
if thinking_open { thinking_open = false; yield ResponseEvent::ThinkingEnd; }
if !text_open { text_open = true; yield ResponseEvent::TextStart; }
yield ResponseEvent::TextDelta(content.into());
}
if let Some(tool_deltas) = delta.get("tool_calls").and_then(Value::as_array) {
for tool in tool_deltas {
let index = tool.get("index").and_then(Value::as_u64)
.ok_or_else(|| Error::Provider("OpenAI Chat tool delta is missing index".into()))? as usize;
let id = tool.get("id").and_then(Value::as_str);
let function = tool.get("function").unwrap_or(&Value::Null);
let name = function.get("name").and_then(Value::as_str);
match tools.entry(index) {
Entry::Vacant(entry) => {
let id = id.ok_or_else(|| Error::Provider("OpenAI Chat tool start is missing id".into()))?;
let name = name.ok_or_else(|| Error::Provider("OpenAI Chat tool start is missing name".into()))?;
entry.insert((id.into(), name.into()));
yield ResponseEvent::ToolCallStart { index, call_id: id.into(), name: name.into() };
}
Entry::Occupied(mut entry) => {
if let Some(id) = id { entry.get_mut().0.push_str(id); }
if let Some(name) = name { entry.get_mut().1.push_str(name); }
}
}
if let Some(arguments) = function.get("arguments").and_then(Value::as_str).filter(|text| !text.is_empty()) {
yield ResponseEvent::ToolCallArgumentsDelta { index, delta: arguments.into() };
}
}
}
if let Some(reason) = choice.get("finish_reason").and_then(Value::as_str) {
finish = Some(map_finish(reason)?);
}
}
if thinking_open { yield ResponseEvent::ThinkingEnd; }
if text_open { yield ResponseEvent::TextEnd; }
for index in tools.keys().copied() { yield ResponseEvent::ToolCallEnd { index }; }
yield ResponseEvent::Done(finish.ok_or_else(|| Error::Provider("OpenAI Chat stream ended without finish_reason".into()))?);
})
}
}
fn openai_chat_messages(messages: &[crate::prompting::ProviderMessage]) -> Vec<Value> {
messages
.iter()
.map(|message| {
let mut output = Map::new();
output.insert("role".into(), Value::String(message.role.clone()));
output.insert("content".into(), message.content.clone());
if let Some(thinking) = &message.thinking {
output.insert("reasoning_content".into(), Value::String(thinking.clone()));
}
if let Some(tool_call_id) = &message.tool_call_id {
output.insert("tool_call_id".into(), Value::String(tool_call_id.clone()));
}
if let Some(tool_calls) = &message.tool_calls {
output.insert("tool_calls".into(), Value::Array(tool_calls.clone()));
}
Value::Object(output)
})
.collect()
}
fn map_finish(value: &str) -> crate::Result<FinishReason> {
match value {
"tool_calls" | "function_call" => Ok(FinishReason::ToolUse),
"length" => Ok(FinishReason::Length),
"stop" => Ok(FinishReason::Stop),
other => Err(Error::Provider(format!(
"unknown OpenAI Chat finish_reason: {other}"
))),
}
}
pub(crate) fn openai_usage(value: &Value) -> Usage {
Usage {
input_tokens: value
.get("prompt_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
output_tokens: value
.get("completion_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
cache_read_tokens: value
.pointer("/prompt_tokens_details/cached_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
cache_write_tokens: 0,
reasoning_tokens: value
.pointer("/completion_tokens_details/reasoning_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
}
}
#[cfg(test)]
mod tests {
use super::openai_chat_messages;
use crate::prompting::ProviderMessage;
use serde_json::{json, Value};
#[test]
fn assistant_thinking_is_encoded_as_reasoning_content() {
let messages = openai_chat_messages(&[ProviderMessage {
role: "assistant".into(),
content: Value::String("visible answer".into()),
thinking: Some("private reasoning".into()),
tool_call_id: None,
tool_calls: Some(vec![json!({
"id": "call-1",
"type": "function",
"function": {"name": "Read", "arguments": "{}"}
})]),
}]);
assert_eq!(messages[0]["content"], "visible answer");
assert_eq!(messages[0]["reasoning_content"], "private reasoning");
assert_eq!(messages[0]["tool_calls"][0]["id"], "call-1");
}
}
@@ -0,0 +1,182 @@
use async_stream::try_stream;
use eventsource_stream::Eventsource;
use futures_util::StreamExt;
use serde_json::{json, Value};
use crate::{config::ProviderConfig, model::Usage, prompting::ModelRequest, Error, Result};
use super::{FinishReason, Provider, ProviderStream, ResponseEvent};
pub struct OpenAiResponsesProvider {
client: reqwest::Client,
config: ProviderConfig,
}
impl OpenAiResponsesProvider {
pub fn new(client: reqwest::Client, config: ProviderConfig) -> Self {
Self { client, config }
}
}
impl Provider for OpenAiResponsesProvider {
fn stream(
&self,
request: ModelRequest,
cancellation: tokio_util::sync::CancellationToken,
) -> ProviderStream {
let client = self.client.clone();
let config = self.config.clone();
Box::pin(try_stream! {
let input = responses_input(&request.messages)?;
let body = json!({
"model": request.model, "input": input, "stream": true,
"tools": request.tools.iter().map(|tool| json!({
"type":"function", "name":tool.name, "description":tool.description,
"parameters":tool.input_schema, "strict":false
})).collect::<Vec<_>>()
});
let response = client.post(format!("{}/responses", config.base_url))
.bearer_auth(&config.api_key).json(&body).send().await?;
if !response.status().is_success() {
let status = response.status(); let text = response.text().await?;
Err(Error::Provider(format!("OpenAI Responses {status}: {text}")))?;
return;
}
yield ResponseEvent::Start { model_call_id: request.model_call_id };
let mut source = response.bytes_stream().eventsource();
let mut text_open = false;
let mut thinking_open = false;
let mut tool_indices = std::collections::BTreeSet::new();
let mut finish = FinishReason::Stop;
loop {
let event = tokio::select! {
_ = cancellation.cancelled() => { yield ResponseEvent::Done(FinishReason::Aborted); return; }
event = source.next() => event,
};
let Some(event) = event else { break };
let event = event.map_err(|error| Error::Provider(format!("OpenAI Responses SSE: {error}")))?;
if event.data == "[DONE]" { break; }
let value: Value = serde_json::from_str(&event.data)?;
let kind = value.get("type").and_then(Value::as_str).unwrap_or(&event.event);
match kind {
"response.output_text.delta" => {
if thinking_open { thinking_open = false; yield ResponseEvent::ThinkingEnd; }
if !text_open { text_open = true; yield ResponseEvent::TextStart; }
if let Some(delta) = value.get("delta").and_then(Value::as_str) { yield ResponseEvent::TextDelta(delta.into()); }
}
"response.reasoning_summary_text.delta" => {
if !thinking_open { thinking_open = true; yield ResponseEvent::ThinkingStart; }
if let Some(delta) = value.get("delta").and_then(Value::as_str) { yield ResponseEvent::ThinkingDelta(delta.into()); }
}
"response.output_item.added" => {
let item = value.get("item").unwrap_or(&Value::Null);
if item.get("type").and_then(Value::as_str) == Some("function_call") {
let index = required_u64(&value, "output_index")? as usize;
let call_id = required_string(item, "call_id")?.to_string();
let name = required_string(item, "name")?.to_string();
tool_indices.insert(index); finish = FinishReason::ToolUse;
yield ResponseEvent::ToolCallStart { index, call_id, name };
}
}
"response.function_call_arguments.delta" => {
let index = required_u64(&value, "output_index")? as usize;
if let Some(delta) = value.get("delta").and_then(Value::as_str) {
yield ResponseEvent::ToolCallArgumentsDelta { index, delta: delta.into() };
}
}
"response.function_call_arguments.done" => {
let index = required_u64(&value, "output_index")? as usize;
if tool_indices.remove(&index) { yield ResponseEvent::ToolCallEnd { index }; }
}
"response.completed" => {
if let Some(usage) = value.pointer("/response/usage") { yield ResponseEvent::Usage(responses_usage(usage)); }
}
"response.incomplete" => finish = FinishReason::Length,
"response.failed" => finish = FinishReason::Error,
_ => {}
}
}
if thinking_open { yield ResponseEvent::ThinkingEnd; }
if text_open { yield ResponseEvent::TextEnd; }
for index in tool_indices { yield ResponseEvent::ToolCallEnd { index }; }
yield ResponseEvent::Done(finish);
})
}
}
fn responses_input(messages: &[crate::prompting::ProviderMessage]) -> Result<Vec<Value>> {
let mut input = Vec::new();
for message in messages {
if message.role == "tool" {
input.push(json!({
"type": "function_call_output",
"call_id": message.tool_call_id.as_deref().ok_or_else(|| Error::Protocol("tool message is missing call_id".into()))?,
"output": content_text(&message.content)?,
}));
continue;
}
let content_type = if message.role == "assistant" {
"output_text"
} else {
"input_text"
};
let text = content_text(&message.content)?;
if !text.is_empty() {
input.push(json!({
"type": "message", "role": message.role,
"content": [{"type": content_type, "text": text}]
}));
}
for call in message.tool_calls.iter().flatten() {
input.push(json!({
"type": "function_call",
"call_id": required_string(call, "id")?,
"name": call.pointer("/function/name").and_then(Value::as_str).ok_or_else(|| Error::Protocol("tool call is missing function.name".into()))?,
"arguments": call.pointer("/function/arguments").and_then(Value::as_str).ok_or_else(|| Error::Protocol("tool call is missing function.arguments".into()))?,
}));
}
}
Ok(input)
}
fn content_text(value: &Value) -> Result<&str> {
value
.as_str()
.ok_or_else(|| Error::Protocol("provider message content must be a string".into()))
}
fn required_string<'a>(value: &'a Value, name: &str) -> Result<&'a str> {
value
.get(name)
.and_then(Value::as_str)
.ok_or_else(|| Error::Provider(format!("OpenAI Responses event is missing {name}")))
}
fn required_u64(value: &Value, name: &str) -> Result<u64> {
value
.get(name)
.and_then(Value::as_u64)
.ok_or_else(|| Error::Provider(format!("OpenAI Responses event is missing {name}")))
}
fn responses_usage(value: &Value) -> Usage {
Usage {
input_tokens: value
.get("input_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
output_tokens: value
.get("output_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
cache_read_tokens: value
.pointer("/input_tokens_details/cached_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
cache_write_tokens: 0,
reasoning_tokens: value
.pointer("/output_tokens_details/reasoning_tokens")
.and_then(Value::as_u64)
.unwrap_or(0),
}
}
+174
View File
@@ -0,0 +1,174 @@
use std::sync::Arc;
use tokio::sync::mpsc;
use crate::{
cursor::{
blob_sync::BlobSynchronizer,
exec,
pending::{PendingClientTools, PendingExecRegistry},
proto::agent::v1 as pb,
tool_result::tool_result_channel,
tools::{ClientToolEvent, ToolDispatcher},
},
model::Usage,
prompting::PromptCompiler,
provider::Provider,
store::{RunStatus, Store},
};
use super::{lifecycle, LoopEngine, OrderedInbox, RunCommand, RunHandle};
pub struct RunActor;
#[derive(Clone)]
pub(crate) struct RunDependencies {
pub store: Store,
pub provider: Arc<dyn Provider>,
pub compiler: PromptCompiler,
pub model: String,
}
impl RunActor {
pub(crate) fn spawn(
handle: RunHandle,
mut receiver: mpsc::Receiver<RunCommand>,
dependencies: RunDependencies,
blob_sync: BlobSynchronizer,
next_append_seqno: i64,
) {
tokio::spawn(async move {
let store = dependencies.store.clone();
let mut inbox = OrderedInbox::starting_at(next_append_seqno);
let (results_tx, results_rx) = tool_result_channel();
let pending_tools = PendingClientTools::default();
let pending_execs = PendingExecRegistry::default();
let tools = ToolDispatcher::new(pending_execs.clone(), pending_tools);
let mut engine = Some(LoopEngine::new(
handle.clone(),
dependencies,
blob_sync.clone(),
results_rx,
tools.clone(),
pending_execs.clone(),
));
let cancellation = handle.cancellation();
loop {
let command = tokio::select! {
_ = cancellation.cancelled() => {
let _ = store
.update_run_status(
handle.request_id(),
RunStatus::Interrupted,
Usage::default(),
)
.await;
for id in pending_execs.drain_running().await {
let _ = handle.emit(&exec::abort(id));
}
let _ = lifecycle::cancel(&handle);
break;
}
command = receiver.recv() => {
let Some(command) = command else { break };
command
}
};
match command {
RunCommand::Abort => {
handle.cancel();
}
RunCommand::Finished => {
break;
}
RunCommand::Append { seqno, message } => {
for (seqno, message) in inbox.push(seqno, *message) {
if store
.advance_append_seqno(handle.request_id(), seqno)
.await
.unwrap_or(false)
{
match message.message {
Some(pb::agent_client_message::Message::RunRequest(
request,
)) => {
if let Some(engine) = engine.take() {
tokio::spawn(engine.run(request));
}
}
Some(pb::agent_client_message::Message::ExecClientMessage(
message,
)) => {
match exec::client_event(&message, &pending_execs).await {
Ok(exec::ClientExecEvent::Delta(message)) => {
let _ = handle.emit(&message);
}
Ok(exec::ClientExecEvent::Completed(result)) => {
results_tx.send(*result)
}
Ok(exec::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 pending_execs.take(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 pending_execs.take(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;
}
_ => {}
}
}
}
}
}
}
});
}
}
+11
View File
@@ -0,0 +1,11 @@
use crate::cursor::proto::agent::v1 as pb;
#[derive(Debug)]
pub enum RunCommand {
Append {
seqno: i64,
message: Box<pb::AgentClientMessage>,
},
Abort,
Finished,
}
+38
View File
@@ -0,0 +1,38 @@
use std::collections::BTreeMap;
#[derive(Debug)]
pub struct OrderedInbox<T> {
next: i64,
pending: BTreeMap<i64, T>,
}
impl<T> Default for OrderedInbox<T> {
fn default() -> Self {
Self {
next: 0,
pending: BTreeMap::new(),
}
}
}
impl<T> OrderedInbox<T> {
pub fn starting_at(next: i64) -> Self {
Self {
next,
pending: BTreeMap::new(),
}
}
pub fn push(&mut self, seqno: i64, value: T) -> Vec<(i64, T)> {
if seqno < self.next {
return Vec::new();
}
self.pending.entry(seqno).or_insert(value);
let mut ready = Vec::new();
while let Some(value) = self.pending.remove(&self.next) {
ready.push((self.next, value));
self.next += 1;
}
ready
}
}
+103
View File
@@ -0,0 +1,103 @@
use crate::{
cursor::{
checkpoint::CheckpointBuilder,
connect::{
encode_end_stream, encode_error_end_stream, ConnectCode, ConnectErrorDetail,
ConnectStreamError,
},
proto::{agent::v1 as pb, aiserver::v1 as ai},
},
model::Usage,
run::RunHandle,
Error, Result,
};
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine};
use prost::Message;
pub async fn publish_success(
handle: &RunHandle,
usage: Usage,
checkpoint_builder: &CheckpointBuilder,
checkpoint: Option<&pb::ConversationStateStructure>,
) -> Result<()> {
handle.emit(&crate::cursor::interaction::turn_ended(usage))?;
if let Some(checkpoint) = checkpoint {
checkpoint_builder.publish(handle, checkpoint).await?;
let message = pb::AgentServerMessage {
ttft_breakdown: None,
message: Some(
pb::agent_server_message::Message::ConversationCheckpointUpdate(checkpoint.clone()),
),
};
handle.emit(&message)?;
}
Ok(())
}
pub fn finish_success(handle: &RunHandle) {
handle.emit_frame(encode_end_stream());
handle.close_output();
}
pub fn fail(handle: &RunHandle, 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::Database(_)
| Error::Migration(_)
| Error::Encode(_)
| Error::Io(_) => plain_error(ConnectCode::Internal, error),
};
let frame = encode_error_end_stream(&stream_error)?;
handle.emit_frame(frame);
handle.close_output();
Ok(())
}
pub fn cancel(handle: &RunHandle) -> Result<()> {
let frame = encode_error_end_stream(&ConnectStreamError {
code: ConnectCode::Canceled,
message: "run was cancelled".into(),
details: Vec::new(),
})?;
handle.emit_frame(frame);
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()),
}],
}
}
File diff suppressed because it is too large Load Diff
+13
View File
@@ -0,0 +1,13 @@
mod actor;
mod command;
mod inbox;
mod lifecycle;
mod loop_engine;
mod registry;
pub use actor::*;
pub use command::*;
pub use inbox::*;
pub use lifecycle::*;
pub use loop_engine::*;
pub use registry::*;
+200
View File
@@ -0,0 +1,200 @@
use std::{collections::HashMap, sync::Arc};
use bytes::Bytes;
use tokio::sync::{mpsc, Mutex};
use tokio_util::sync::CancellationToken;
use crate::{
cursor::{blob_sync::BlobSynchronizer, proto::agent::v1 as pb},
prompting::PromptCompiler,
provider::Provider,
store::Store,
Result,
};
use super::{actor::RunDependencies, RunActor, RunCommand};
#[derive(Clone)]
pub struct RunHandle {
request_id: String,
commands: mpsc::Sender<RunCommand>,
output: Arc<OutputHub>,
cancellation: CancellationToken,
}
impl RunHandle {
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: RunCommand) -> 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()
}
}
#[derive(Default)]
struct OutputHub {
state: std::sync::Mutex<OutputState>,
}
#[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().expect("output hub 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().expect("output hub 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().expect("output hub lock");
state.closed = true;
state.subscribers.clear();
}
}
#[derive(Clone)]
pub struct RunRegistry {
inner: Arc<RegistryInner>,
}
struct RegistryInner {
runs: Mutex<HashMap<String, RunHandle>>,
conversations: Mutex<HashMap<String, String>>,
store: Store,
provider: Arc<dyn Provider>,
compiler: PromptCompiler,
model: String,
}
impl RunRegistry {
pub fn new(
store: Store,
provider: Arc<dyn Provider>,
compiler: PromptCompiler,
model: String,
) -> Self {
Self {
inner: Arc::new(RegistryInner {
runs: Mutex::new(HashMap::new()),
conversations: Mutex::new(HashMap::new()),
store,
provider,
compiler,
model,
}),
}
}
pub async fn get_or_create(&self, request_id: &str) -> Result<RunHandle> {
if let Some(handle) = self.inner.runs.lock().await.get(request_id).cloned() {
return Ok(handle);
}
self.inner.store.create_pending_run(request_id).await?;
let next_append_seqno = self.inner.store.next_append_seqno(request_id).await?;
let (commands, receiver) = mpsc::channel(128);
let output = Arc::new(OutputHub::default());
let cancellation = CancellationToken::new();
let handle = RunHandle {
request_id: request_id.into(),
commands,
output,
cancellation,
};
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());
let recovery = blob_sync.clone();
tokio::spawn(async move {
if let Err(error) = recovery.recover().await {
tracing::error!(%error, "failed to replay Run outbox");
}
});
RunActor::spawn(
handle.clone(),
receiver,
RunDependencies {
store: self.inner.store.clone(),
provider: self.inner.provider.clone(),
compiler: self.inner.compiler.clone(),
model: self.inner.model.clone(),
},
blob_sync,
next_append_seqno,
);
Ok(handle)
}
pub async fn bind_conversation(&self, conversation_id: &str, request_id: &str) {
let previous = self
.inner
.conversations
.lock()
.await
.insert(conversation_id.into(), request_id.into());
if let Some(previous) = previous.filter(|previous| previous != request_id) {
if let Some(handle) = self.inner.runs.lock().await.get(&previous).cloned() {
handle.cancel();
}
}
}
pub async fn shutdown(&self) {
let handles = {
let mut runs = self.inner.runs.lock().await;
runs.drain().map(|(_, handle)| handle).collect::<Vec<_>>()
};
self.inner.conversations.lock().await.clear();
for handle in handles {
handle.cancel();
}
}
}
+96
View File
@@ -0,0 +1,96 @@
use base64::{engine::general_purpose::STANDARD, Engine};
use sha2::{Digest, Sha256};
use sqlx::Row;
use crate::{Error, Result};
use super::{now_ms, Store};
#[derive(Clone, Debug, PartialEq, Eq, Hash)]
pub struct BlobId([u8; 32]);
impl BlobId {
pub fn digest(data: &[u8]) -> Self {
Self(Sha256::digest(data).into())
}
pub fn from_bytes(bytes: &[u8]) -> Result<Self> {
let value: [u8; 32] = bytes.try_into().map_err(|_| {
Error::Protocol(format!("BlobID must be 32 bytes, got {}", bytes.len()))
})?;
Ok(Self(value))
}
pub fn from_base64(value: &str) -> Result<Self> {
let decoded = STANDARD
.decode(value)
.map_err(|error| Error::Protocol(format!("invalid BlobID base64: {error}")))?;
Self::from_bytes(&decoded)
}
pub fn as_bytes(&self) -> &[u8; 32] {
&self.0
}
pub fn to_base64(&self) -> String {
STANDARD.encode(self.0)
}
}
#[derive(Clone, Debug)]
pub struct BlobEdge {
pub child: BlobId,
pub field_name: String,
}
impl Store {
pub async fn put_blob(&self, data: &[u8], edges: &[BlobEdge]) -> Result<BlobId> {
let blob_id = BlobId::digest(data);
let mut tx = self.pool.begin().await?;
sqlx::query("INSERT OR IGNORE INTO blobs(blob_id, data, created_at_ms) VALUES (?, ?, ?)")
.bind(blob_id.as_bytes().as_slice())
.bind(data)
.bind(now_ms())
.execute(&mut *tx)
.await?;
for edge in edges {
sqlx::query(
"INSERT OR IGNORE INTO blob_edges(parent_blob_id, child_blob_id, field_name) VALUES (?, ?, ?)",
)
.bind(blob_id.as_bytes().as_slice())
.bind(edge.child.as_bytes().as_slice())
.bind(&edge.field_name)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
Ok(blob_id)
}
pub async fn get_blob(&self, blob_id: &BlobId) -> Result<Option<Vec<u8>>> {
Ok(sqlx::query("SELECT data FROM blobs WHERE blob_id = ?")
.bind(blob_id.as_bytes().as_slice())
.fetch_optional(&self.pool)
.await?
.map(|row| row.get(0)))
}
pub async fn blob_closure(&self, roots: &[BlobId]) -> Result<Vec<BlobId>> {
let mut seen = std::collections::HashSet::new();
let mut stack = roots.to_vec();
while let Some(id) = stack.pop() {
if !seen.insert(id.clone()) {
continue;
}
let rows = sqlx::query("SELECT child_blob_id FROM blob_edges WHERE parent_blob_id = ?")
.bind(id.as_bytes().as_slice())
.fetch_all(&self.pool)
.await?;
for row in rows {
stack.push(BlobId::from_bytes(row.get::<Vec<u8>, _>(0).as_slice())?);
}
}
let mut closure: Vec<_> = seen.into_iter().collect();
closure.sort_by(|left, right| left.as_bytes().cmp(right.as_bytes()));
Ok(closure)
}
}
+72
View File
@@ -0,0 +1,72 @@
use sqlx::Row;
use crate::{model::Conversation, Result};
use super::{now_ms, BlobId, Store};
impl Store {
pub async fn begin_revision(&self, conversation_id: &str) -> Result<i64> {
let mut tx = self.pool.begin().await?;
Self::ensure_conversation_tx(&mut tx, conversation_id).await?;
sqlx::query("UPDATE conversations SET revision = revision + 1, updated_at_ms = ? WHERE conversation_id = ?")
.bind(now_ms())
.bind(conversation_id)
.execute(&mut *tx)
.await?;
let revision =
sqlx::query_scalar("SELECT revision FROM conversations WHERE conversation_id = ?")
.bind(conversation_id)
.fetch_one(&mut *tx)
.await?;
tx.commit().await?;
Ok(revision)
}
pub async fn revision_is_current(&self, conversation_id: &str, revision: i64) -> Result<bool> {
let current: Option<i64> =
sqlx::query_scalar("SELECT revision FROM conversations WHERE conversation_id = ?")
.bind(conversation_id)
.fetch_optional(&self.pool)
.await?;
Ok(current == Some(revision))
}
pub async fn publish_head(
&self,
conversation_id: &str,
revision: i64,
head: &BlobId,
) -> Result<bool> {
let affected = sqlx::query(
"UPDATE conversations SET head_blob_id = ?, updated_at_ms = ? WHERE conversation_id = ? AND revision = ?",
)
.bind(head.as_bytes().as_slice())
.bind(now_ms())
.bind(conversation_id)
.bind(revision)
.execute(&self.pool)
.await?
.rows_affected();
Ok(affected == 1)
}
pub async fn conversation(&self, conversation_id: &str) -> Result<Option<Conversation>> {
let row = sqlx::query(
"SELECT conversation_id, revision, head_blob_id FROM conversations WHERE conversation_id = ?",
)
.bind(conversation_id)
.fetch_optional(&self.pool)
.await?;
row.map(|row| {
let head: Option<Vec<u8>> = row.get(2);
Ok(Conversation {
conversation_id: row.get(0),
revision: row.get(1),
head_blob_id: head
.map(|bytes| BlobId::from_bytes(&bytes).map(|id| id.to_base64()))
.transpose()?,
})
})
.transpose()
}
}
+132
View File
@@ -0,0 +1,132 @@
use sqlx::{Row, Sqlite, Transaction};
use crate::{
model::{CanonicalMessage, RuntimeEvent},
Result,
};
use super::{now_ms, Store};
impl Store {
pub async fn load_messages(&self, conversation_id: &str) -> Result<Vec<CanonicalMessage>> {
let rows = sqlx::query(
"SELECT payload_json FROM messages WHERE conversation_id = ? ORDER BY message_seq",
)
.bind(conversation_id)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| serde_json::from_str(row.get::<&str, _>(0)).map_err(Into::into))
.collect()
}
pub async fn append_messages(
&self,
conversation_id: &str,
messages: &[CanonicalMessage],
) -> Result<()> {
let mut tx = self.pool.begin().await?;
Self::ensure_conversation_tx(&mut tx, conversation_id).await?;
for message in messages {
Self::append_message_tx(&mut tx, conversation_id, message).await?;
}
tx.commit().await?;
Ok(())
}
pub async fn append_runtime_event_once(
&self,
conversation_id: &str,
event: RuntimeEvent,
) -> Result<bool> {
let message = event.into_message();
let mut tx = self.pool.begin().await?;
Self::ensure_conversation_tx(&mut tx, conversation_id).await?;
let next_seq: i64 = sqlx::query_scalar(
"SELECT COALESCE(MAX(message_seq), -1) + 1 FROM messages WHERE conversation_id = ?",
)
.bind(conversation_id)
.fetch_one(&mut *tx)
.await?;
let inserted = sqlx::query(
"INSERT OR IGNORE INTO messages
(conversation_id, message_seq, message_id, role, origin, payload_json, runtime_event_id, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(conversation_id)
.bind(next_seq)
.bind(&message.message_id)
.bind(role_name(&message.role))
.bind(origin_name(&message.origin))
.bind(serde_json::to_string(&message)?)
.bind(&message.runtime_event_id)
.bind(now_ms())
.execute(&mut *tx)
.await?
.rows_affected() == 1;
tx.commit().await?;
Ok(inserted)
}
async fn append_message_tx(
tx: &mut Transaction<'_, Sqlite>,
conversation_id: &str,
message: &CanonicalMessage,
) -> Result<()> {
let next_seq: i64 = sqlx::query_scalar(
"SELECT COALESCE(MAX(message_seq), -1) + 1 FROM messages WHERE conversation_id = ?",
)
.bind(conversation_id)
.fetch_one(&mut **tx)
.await?;
sqlx::query(
"INSERT OR IGNORE INTO messages
(conversation_id, message_seq, message_id, role, origin, payload_json, runtime_event_id, created_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?, ?)",
)
.bind(conversation_id)
.bind(next_seq)
.bind(&message.message_id)
.bind(role_name(&message.role))
.bind(origin_name(&message.origin))
.bind(serde_json::to_string(message)?)
.bind(&message.runtime_event_id)
.bind(now_ms())
.execute(&mut **tx)
.await?;
Ok(())
}
pub(crate) async fn ensure_conversation_tx(
tx: &mut Transaction<'_, Sqlite>,
conversation_id: &str,
) -> Result<()> {
sqlx::query(
"INSERT OR IGNORE INTO conversations(conversation_id, revision, updated_at_ms) VALUES (?, 0, ?)",
)
.bind(conversation_id)
.bind(now_ms())
.execute(&mut **tx)
.await?;
Ok(())
}
}
fn role_name(role: &crate::model::Role) -> &'static str {
match role {
crate::model::Role::System => "system",
crate::model::Role::User => "user",
crate::model::Role::Assistant => "assistant",
crate::model::Role::Tool => "tool",
}
}
fn origin_name(origin: &crate::model::Origin) -> &'static str {
match origin {
crate::model::Origin::Prompt => "prompt",
crate::model::Origin::User => "user",
crate::model::Origin::Runtime => "runtime",
crate::model::Origin::Assistant => "assistant",
crate::model::Origin::Tool => "tool",
}
}
+12
View File
@@ -0,0 +1,12 @@
mod blobs;
mod conversations;
mod messages;
mod outbox;
mod runs;
mod sqlite;
pub use blobs::*;
pub use outbox::*;
pub use runs::*;
pub(crate) use sqlite::now_ms;
pub use sqlite::Store;
+115
View File
@@ -0,0 +1,115 @@
use sqlx::Row;
use crate::Result;
use super::{now_ms, BlobId, Store};
#[derive(Clone, Debug)]
pub struct OutboxItem {
pub id: i64,
pub request_id: String,
pub key: String,
pub kind: String,
pub payload: Vec<u8>,
pub dependencies: Vec<String>,
pub attempts: i64,
}
impl Store {
pub async fn enqueue_outbox(
&self,
request_id: &str,
key: &str,
kind: &str,
payload: &[u8],
dependencies: &[BlobId],
) -> Result<()> {
let now = now_ms();
let dependency_json = serde_json::to_string(
&dependencies
.iter()
.map(BlobId::to_base64)
.collect::<Vec<_>>(),
)?;
sqlx::query(
"INSERT OR IGNORE INTO outbox
(request_id, operation_key, operation_kind, payload, dependency_blob_ids_json, created_at_ms, updated_at_ms)
VALUES (?, ?, ?, ?, ?, ?, ?)",
)
.bind(request_id).bind(key).bind(kind).bind(payload).bind(dependency_json).bind(now).bind(now)
.execute(&self.pool).await?;
Ok(())
}
pub async fn pending_outbox(&self, request_id: &str) -> Result<Vec<OutboxItem>> {
let rows = sqlx::query(
"SELECT outbox_id, request_id, operation_key, operation_kind, payload,
dependency_blob_ids_json, attempts
FROM outbox WHERE request_id = ? AND acked_at_ms IS NULL ORDER BY outbox_id",
)
.bind(request_id)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(OutboxItem {
id: row.get(0),
request_id: row.get(1),
key: row.get(2),
kind: row.get(3),
payload: row.get(4),
dependencies: serde_json::from_str(row.get::<&str, _>(5))?,
attempts: row.get(6),
})
})
.collect()
}
pub async fn mark_outbox_sent(&self, id: i64) -> Result<()> {
sqlx::query(
"UPDATE outbox SET attempts = attempts + 1, updated_at_ms = ? WHERE outbox_id = ?",
)
.bind(now_ms())
.bind(id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn ack_outbox(&self, request_id: &str, key: &str) -> Result<bool> {
Ok(sqlx::query(
"UPDATE outbox SET acked_at_ms = COALESCE(acked_at_ms, ?), updated_at_ms = ?
WHERE request_id = ? AND operation_key = ?",
)
.bind(now_ms())
.bind(now_ms())
.bind(request_id)
.bind(key)
.execute(&self.pool)
.await?
.rows_affected()
== 1)
}
pub async fn dependencies_acked(
&self,
request_id: &str,
dependencies: &[BlobId],
) -> Result<bool> {
for id in dependencies {
let key = format!("blob:{}", id.to_base64());
let acked: Option<i64> = sqlx::query_scalar(
"SELECT acked_at_ms FROM outbox WHERE request_id = ? AND operation_key = ?",
)
.bind(request_id)
.bind(key)
.fetch_optional(&self.pool)
.await?
.flatten();
if acked.is_none() {
return Ok(false);
}
}
Ok(true)
}
}
+162
View File
@@ -0,0 +1,162 @@
use sqlx::Row;
use crate::{
model::{ToolResult, Usage},
Result,
};
use super::{now_ms, Store};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum RunStatus {
Waiting,
Running,
Completed,
Interrupted,
Failed,
}
impl RunStatus {
fn as_str(self) -> &'static str {
match self {
Self::Waiting => "waiting",
Self::Running => "running",
Self::Completed => "completed",
Self::Interrupted => "interrupted",
Self::Failed => "failed",
}
}
}
impl Store {
pub async fn create_pending_run(&self, request_id: &str) -> Result<()> {
let now = now_ms();
sqlx::query(
"INSERT OR IGNORE INTO runs(request_id, status, created_at_ms, updated_at_ms) VALUES (?, 'waiting', ?, ?)",
)
.bind(request_id).bind(now).bind(now)
.execute(&self.pool).await?;
Ok(())
}
pub async fn next_append_seqno(&self, request_id: &str) -> Result<i64> {
let current: i64 = sqlx::query_scalar("SELECT append_seqno FROM runs WHERE request_id = ?")
.bind(request_id)
.fetch_one(&self.pool)
.await?;
Ok(current + 1)
}
pub async fn bind_run(
&self,
request_id: &str,
run_id: &str,
conversation_id: &str,
revision: i64,
) -> Result<()> {
let mut tx = self.pool.begin().await?;
Self::ensure_conversation_tx(&mut tx, conversation_id).await?;
sqlx::query(
"UPDATE runs SET run_id = ?, conversation_id = ?, revision = ?, status = 'running', updated_at_ms = ? WHERE request_id = ?",
)
.bind(run_id).bind(conversation_id).bind(revision).bind(now_ms()).bind(request_id)
.execute(&mut *tx).await?;
tx.commit().await?;
Ok(())
}
pub async fn advance_append_seqno(&self, request_id: &str, seqno: i64) -> Result<bool> {
let changed = sqlx::query(
"UPDATE runs SET append_seqno = ?, updated_at_ms = ? WHERE request_id = ? AND append_seqno < ?",
)
.bind(seqno).bind(now_ms()).bind(request_id).bind(seqno)
.execute(&self.pool).await?.rows_affected() == 1;
Ok(changed)
}
pub async fn update_run_status(
&self,
request_id: &str,
status: RunStatus,
usage: Usage,
) -> Result<()> {
sqlx::query("UPDATE runs SET status = ?, turn_usage_json = ?, updated_at_ms = ? WHERE request_id = ?")
.bind(status.as_str()).bind(serde_json::to_string(&usage)?).bind(now_ms()).bind(request_id)
.execute(&self.pool).await?;
Ok(())
}
pub async fn begin_provider_call(&self, request_id: &str, call_index: usize) -> Result<()> {
sqlx::query(
"UPDATE runs SET provider_call_index = ?, updated_at_ms = ? WHERE request_id = ?",
)
.bind(call_index as i64)
.bind(now_ms())
.bind(request_id)
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn save_tool_result(
&self,
request_id: &str,
batch_index: usize,
call_index: usize,
result: &ToolResult,
) -> Result<()> {
sqlx::query(
"INSERT OR IGNORE INTO run_tool_results
(request_id, batch_index, call_index, completion_seq, call_id, output_json, is_error, completed_at_ms)
VALUES (?, ?, ?,
(SELECT COALESCE(MAX(completion_seq), -1) + 1 FROM run_tool_results
WHERE request_id = ? AND batch_index = ?),
?, ?, ?, ?)",
)
.bind(request_id)
.bind(batch_index as i64)
.bind(call_index as i64)
.bind(request_id)
.bind(batch_index as i64)
.bind(&result.call_id)
.bind(serde_json::to_string(&result.output)?)
.bind(result.is_error)
.bind(now_ms())
.execute(&self.pool)
.await?;
Ok(())
}
pub async fn load_tool_results(
&self,
request_id: &str,
batch_index: usize,
) -> Result<Vec<ToolResult>> {
let rows = sqlx::query(
"SELECT call_id, output_json, is_error FROM run_tool_results
WHERE request_id = ? AND batch_index = ? ORDER BY completion_seq",
)
.bind(request_id)
.bind(batch_index as i64)
.fetch_all(&self.pool)
.await?;
rows.into_iter()
.map(|row| {
Ok(ToolResult {
call_id: row.get(0),
output: serde_json::from_str(row.get::<&str, _>(1))?,
is_error: row.get(2),
})
})
.collect()
}
pub async fn clear_tool_results(&self, request_id: &str, batch_index: usize) -> Result<()> {
sqlx::query("DELETE FROM run_tool_results WHERE request_id = ? AND batch_index = ?")
.bind(request_id)
.bind(batch_index as i64)
.execute(&self.pool)
.await?;
Ok(())
}
}
+41
View File
@@ -0,0 +1,41 @@
use std::{str::FromStr, time::Duration};
use sqlx::{
sqlite::{SqliteConnectOptions, SqliteJournalMode, SqlitePoolOptions, SqliteSynchronous},
SqlitePool,
};
use crate::Result;
#[derive(Clone)]
pub struct Store {
pub(crate) pool: SqlitePool,
}
impl Store {
pub async fn connect(database_url: &str) -> Result<Self> {
let options = SqliteConnectOptions::from_str(database_url)?
.create_if_missing(true)
.foreign_keys(true)
.journal_mode(SqliteJournalMode::Wal)
.synchronous(SqliteSynchronous::Full)
.busy_timeout(Duration::from_secs(5));
let pool = SqlitePoolOptions::new()
.max_connections(8)
.connect_with(options)
.await?;
sqlx::migrate!("./migrations").run(&pool).await?;
Ok(Self { pool })
}
pub fn pool(&self) -> &SqlitePool {
&self.pool
}
}
pub fn now_ms() -> i64 {
std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_millis() as i64
}
@@ -0,0 +1,79 @@
#[path = "support/fake_provider.rs"]
mod fake_provider;
#[path = "support/fixtures.rs"]
mod fixtures;
use std::sync::Arc;
use cursor_server::{
cursor::{connect, proto::agent::v1 as pb},
prompting::{PromptAssets, PromptCompiler},
run::RunRegistry,
store::BlobId,
};
use prost::Message;
#[tokio::test]
async fn checkpoint_dependency_is_not_ready_until_blob_ack() {
let (_directory, store) = fixtures::temp_store().await;
store.create_pending_run("request").await.unwrap();
let id = BlobId::digest(b"tool result");
let key = format!("blob:{}", id.to_base64());
store
.enqueue_outbox("request", &key, "kv_set", b"tool result", &[])
.await
.unwrap();
assert!(!store
.dependencies_acked("request", std::slice::from_ref(&id))
.await
.unwrap());
assert!(store.ack_outbox("request", &key).await.unwrap());
assert!(store.dependencies_acked("request", &[id]).await.unwrap());
}
#[tokio::test]
async fn pending_blob_outbox_is_replayed_when_the_run_is_recreated() {
let (_directory, store) = fixtures::temp_store().await;
store.create_pending_run("recover-request").await.unwrap();
let data = b"durable tool result";
let blob_id = store.put_blob(data, &[]).await.unwrap();
store
.enqueue_outbox(
"recover-request",
&format!("blob:{}", blob_id.to_base64()),
"kv_set",
data,
&[],
)
.await
.unwrap();
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../prompt")
.as_path(),
)
.unwrap();
let registry = RunRegistry::new(
store,
Arc::new(fake_provider::FakeProvider::default()),
PromptCompiler::new(assets),
"test".into(),
);
let handle = registry.get_or_create("recover-request").await.unwrap();
let mut output = handle.subscribe();
let frame = tokio::time::timeout(std::time::Duration::from_secs(2), output.recv())
.await
.unwrap()
.unwrap();
let (_, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
let message = pb::AgentServerMessage::decode(payload).unwrap();
let Some(pb::agent_server_message::Message::KvServerMessage(kv)) = message.message else {
panic!("expected replayed KV SET")
};
let Some(pb::kv_server_message::Message::SetBlobArgs(set)) = kv.message else {
panic!("expected SetBlobArgs")
};
assert_eq!(set.blob_id, blob_id.as_bytes());
assert_eq!(set.blob_data, data);
}
+136
View File
@@ -0,0 +1,136 @@
#[path = "support/fake_cursor.rs"]
mod fake_cursor;
#[path = "support/fake_provider.rs"]
mod fake_provider;
#[path = "support/fixtures.rs"]
mod fixtures;
use std::{io::Write, sync::Arc};
use axum::{
body::{to_bytes, Body},
http::{header, Request, StatusCode},
};
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine};
use cursor_server::{
cursor::{
connect, handlers,
proto::{agent::v1 as pb, aiserver::v1 as ai},
},
prompting::{PromptAssets, PromptCompiler},
run::RunRegistry,
};
use flate2::{write::GzEncoder, Compression};
use prost::Message;
use tower::ServiceExt;
#[test]
fn connect_envelope_is_flag_plus_big_endian_length_plus_protobuf() {
let message = pb::BidiRequestId {
request_id: "abc".into(),
};
let frame = connect::encode_message(&message).unwrap();
assert_eq!(&frame[..5], &[0, 0, 0, 0, 5]);
let decoded: pb::BidiRequestId = fake_cursor::decode_single(&frame).unwrap();
assert_eq!(decoded.request_id, "abc");
}
#[test]
fn end_stream_matches_captured_connect_shape() {
assert_eq!(
connect::encode_end_stream().as_ref(),
&[2, 0, 0, 0, 2, b'{', b'}']
);
}
#[test]
fn error_end_stream_is_flagged_json_not_protobuf() {
let frame = connect::encode_error_end_stream(&connect::ConnectStreamError {
code: connect::ConnectCode::Unavailable,
message: "overloaded".into(),
details: vec![connect::ConnectErrorDetail {
type_name: "aiserver.v1.ErrorDetails".into(),
value: "AQ".into(),
}],
})
.unwrap();
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
assert_eq!(flags, connect::END_STREAM_FLAG);
let json: serde_json::Value = serde_json::from_slice(&payload).unwrap();
assert_eq!(json["error"]["code"], "unavailable");
assert_eq!(json["error"]["message"], "overloaded");
assert_eq!(
json["error"]["details"][0]["type"],
"aiserver.v1.ErrorDetails"
);
}
#[test]
fn cursor_error_details_subset_decodes_captured_wire_value() {
let captured = "CAISVQoUQXV0aGVudGljYXRpb24gZXJyb3ISMklmIHlvdSBhcmUgbG9nZ2VkIGluLCB0cnkgbG9nZ2luZyBvdXQgYW5kIGJhY2sgaW4uIABSBwoFbG9naW4YAQ";
let bytes = STANDARD_NO_PAD.decode(captured).unwrap();
let details = ai::ErrorDetails::decode(bytes.as_slice()).unwrap();
assert_eq!(details.error, 2, "ERROR_NOT_LOGGED_IN");
assert_eq!(details.is_expected, Some(true));
let custom = details.details.unwrap();
assert_eq!(custom.title, "Authentication error");
assert_eq!(custom.is_retryable, Some(false));
}
#[test]
fn captured_kv_ack_hex_decodes_as_agent_client_message() {
let bytes = hex::decode("1a0408011a00").unwrap();
let message = pb::AgentClientMessage::decode(bytes.as_slice()).unwrap();
let Some(pb::agent_client_message::Message::KvClientMessage(kv)) = message.message else {
panic!("expected KV client message")
};
assert_eq!(kv.id, 1);
assert!(matches!(
kv.message,
Some(pb::kv_client_message::Message::SetBlobResult(_))
));
}
#[tokio::test]
async fn bidi_append_gzip_body_is_decompressed_before_protobuf_decode() {
let (_directory, store) = fixtures::temp_store().await;
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../prompt")
.as_path(),
)
.unwrap();
let registry = RunRegistry::new(
store,
Arc::new(fake_provider::FakeProvider::default()),
PromptCompiler::new(assets),
"test-model".into(),
);
let wire = ai::BidiAppendRequest {
request_id: Some(ai::BidiRequestId {
request_id: "gzip-request".into(),
}),
..Default::default()
}
.encode_to_vec();
let mut encoder = GzEncoder::new(Vec::new(), Compression::default());
encoder.write_all(&wire).unwrap();
let compressed = encoder.finish().unwrap();
let response = handlers::router(registry)
.oneshot(
Request::post("/aiserver.v1.BidiService/BidiAppend")
.header(header::CONTENT_TYPE, "application/proto")
.header(header::CONTENT_ENCODING, "gzip")
.body(Body::from(compressed))
.unwrap(),
)
.await
.unwrap();
assert_eq!(response.status(), StatusCode::BAD_REQUEST);
let body = to_bytes(response.into_body(), 4096).await.unwrap();
let text = std::str::from_utf8(&body).unwrap();
assert!(text.contains("BidiAppend contains no AgentClientMessage"));
assert!(!text.contains("protobuf decode error"));
}
+306
View File
@@ -0,0 +1,306 @@
#[path = "support/fake_provider.rs"]
mod fake_provider;
#[path = "support/fixtures.rs"]
mod fixtures;
use std::sync::Arc;
use base64::{engine::general_purpose::STANDARD_NO_PAD, Engine};
use cursor_server::{
cursor::{
connect,
proto::{agent::v1 as pb, aiserver::v1 as ai},
},
model::{MessageContent, Role},
prompting::{PromptAssets, PromptCompiler},
provider::{FinishReason, ResponseEvent},
run::{RunCommand, RunRegistry},
Error,
};
use prost::Message;
#[tokio::test]
async fn provider_failure_checkpoints_then_returns_structured_error_and_closes() {
let (_directory, store) = fixtures::temp_store().await;
let provider = fake_provider::FakeProvider::default();
provider.push_error(Error::Provider("provider failed".into()));
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../prompt")
.as_path(),
)
.unwrap();
let registry = RunRegistry::new(
store.clone(),
Arc::new(provider),
PromptCompiler::new(assets),
"test-model".into(),
);
let handle = registry.get_or_create("failed-request").await.unwrap();
let mut output = handle.subscribe();
handle
.command(RunCommand::Append {
seqno: 0,
message: Box::new(client_run()),
})
.await
.unwrap();
let mut append_seqno = 1;
let mut saw_checkpoint = false;
let mut saw_turn_ended = false;
let error_json = loop {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
.await
.unwrap()
.expect("RunSSE closed before EndStream");
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
if flags & connect::END_STREAM_FLAG != 0 {
break serde_json::from_slice::<serde_json::Value>(&payload).unwrap();
}
let server = pb::AgentServerMessage::decode(payload).unwrap();
match server.message {
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
handle
.command(RunCommand::Append {
seqno: append_seqno,
message: Box::new(kv_ack(kv.id)),
})
.await
.unwrap();
append_seqno += 1;
}
Some(pb::agent_server_message::Message::ConversationCheckpointUpdate(_)) => {
saw_checkpoint = true;
}
Some(pb::agent_server_message::Message::InteractionUpdate(update)) => {
if matches!(
update.message,
Some(pb::interaction_update::Message::TurnEnded(_))
) {
saw_turn_ended = true;
}
if let Some(pb::interaction_update::Message::TextDelta(delta)) = update.message {
assert!(!delta.text.contains("Cursor server error"));
}
}
_ => {}
}
};
assert!(saw_checkpoint);
assert!(!saw_turn_ended);
assert_eq!(error_json["error"]["code"], "unavailable");
let detail = &error_json["error"]["details"][0];
assert_eq!(detail["type"], "aiserver.v1.ErrorDetails");
let encoded = detail["value"].as_str().unwrap();
assert!(!encoded.ends_with('='));
let decoded = STANDARD_NO_PAD.decode(encoded).unwrap();
let decoded = ai::ErrorDetails::decode(decoded.as_slice()).unwrap();
assert_eq!(
decoded.error,
ai::error_details::Error::ProviderError as i32
);
let custom = decoded.details.unwrap();
assert_eq!(custom.title, "Server Error");
assert_eq!(custom.is_retryable, Some(true));
assert_eq!(custom.should_show_immediate_error, Some(false));
assert_eq!(
tokio::time::timeout(std::time::Duration::from_secs(1), output.recv())
.await
.unwrap(),
None
);
let messages = store.load_messages("failed-conversation").await.unwrap();
assert!(messages.iter().any(|message| message.role == Role::User));
assert!(!messages.iter().any(|message| {
matches!(
&message.content,
MessageContent::Assistant { text, .. } if text.contains("Cursor server error")
)
}));
}
#[tokio::test]
async fn runtime_protocol_failure_returns_connect_error_end_stream_and_closes() {
let (_directory, store) = fixtures::temp_store().await;
let provider = fake_provider::FakeProvider::default();
provider.push(vec![
ResponseEvent::Start {
model_call_id: "model-call".into(),
},
ResponseEvent::ToolCallStart {
index: 0,
call_id: "call-1".into(),
name: "Read".into(),
},
ResponseEvent::ToolCallArgumentsDelta {
index: 0,
delta: "{\"path\":\"/tmp/a\"}".into(),
},
ResponseEvent::ToolCallEnd { index: 0 },
ResponseEvent::Done(FinishReason::ToolUse),
]);
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../prompt")
.as_path(),
)
.unwrap();
let registry = RunRegistry::new(
store,
Arc::new(provider),
PromptCompiler::new(assets),
"test-model".into(),
);
let handle = registry
.get_or_create("protocol-failed-request")
.await
.unwrap();
let mut output = handle.subscribe();
handle
.command(RunCommand::Append {
seqno: 0,
message: Box::new(protocol_client_run()),
})
.await
.unwrap();
let mut append_seqno = 1;
let mut saw_turn_ended = false;
let error_json = loop {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
.await
.unwrap()
.expect("RunSSE closed before Error EndStream");
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
if flags & connect::END_STREAM_FLAG != 0 {
break serde_json::from_slice::<serde_json::Value>(&payload).unwrap();
}
let server = pb::AgentServerMessage::decode(payload).unwrap();
match server.message {
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
handle
.command(RunCommand::Append {
seqno: append_seqno,
message: Box::new(kv_ack(kv.id)),
})
.await
.unwrap();
append_seqno += 1;
}
Some(pb::agent_server_message::Message::ExecServerMessage(exec)) => {
// An unknown numeric bridge id is a runtime protocol error.
handle
.command(RunCommand::Append {
seqno: append_seqno,
message: Box::new(pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::ExecClientMessage(
pb::ExecClientMessage {
id: exec.id + 1_000,
exec_id: String::new(),
message: None,
..Default::default()
},
)),
}),
})
.await
.unwrap();
append_seqno += 1;
}
Some(pb::agent_server_message::Message::InteractionUpdate(update)) => {
if matches!(
update.message,
Some(pb::interaction_update::Message::TurnEnded(_))
) {
saw_turn_ended = true;
}
if let Some(pb::interaction_update::Message::TextDelta(delta)) = update.message {
assert!(!delta.text.contains("unknown tool result"));
assert!(!delta.text.contains("protocol error"));
}
}
_ => {}
}
};
assert!(!saw_turn_ended);
assert_eq!(error_json["error"]["code"], "invalid_argument");
assert_eq!(
error_json["error"]["message"],
"protocol error: unknown ExecClientMessage id: 1001"
);
assert_eq!(
tokio::time::timeout(std::time::Duration::from_secs(1), output.recv())
.await
.unwrap(),
None
);
}
fn client_run() -> pb::AgentClientMessage {
pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::RunRequest(
pb::AgentRunRequest {
action: Some(pb::ConversationAction {
action: Some(pb::conversation_action::Action::UserMessageAction(
pb::UserMessageAction {
user_message: Some(pb::UserMessage {
text: "hello".into(),
message_id: "failed-user".into(),
mode: pb::AgentMode::Agent as i32,
..Default::default()
}),
..Default::default()
},
)),
..Default::default()
}),
conversation_id: Some("failed-conversation".into()),
run_id: Some("failed-request".into()),
..Default::default()
},
)),
}
}
fn protocol_client_run() -> pb::AgentClientMessage {
pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::RunRequest(
pb::AgentRunRequest {
action: Some(pb::ConversationAction {
action: Some(pb::conversation_action::Action::UserMessageAction(
pb::UserMessageAction {
user_message: Some(pb::UserMessage {
text: "read it".into(),
message_id: "protocol-failed-user".into(),
mode: pb::AgentMode::Agent as i32,
..Default::default()
}),
..Default::default()
},
)),
..Default::default()
}),
conversation_id: Some("protocol-failed-conversation".into()),
run_id: Some("protocol-failed-request".into()),
..Default::default()
},
)),
}
}
fn kv_ack(id: u32) -> pb::AgentClientMessage {
pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::KvClientMessage(
pb::KvClientMessage {
id,
message: Some(pb::kv_client_message::Message::SetBlobResult(
pb::SetBlobResult { error: None },
)),
},
)),
}
}
+190
View File
@@ -0,0 +1,190 @@
#[path = "support/fake_provider.rs"]
mod fake_provider;
#[path = "support/fixtures.rs"]
mod fixtures;
use std::sync::Arc;
use cursor_server::{
cursor::{connect, proto::agent::v1 as pb},
prompting::{PromptAssets, PromptCompiler},
provider::{FinishReason, ResponseEvent},
run::{RunCommand, RunRegistry},
};
use prost::Message;
#[tokio::test]
async fn a_new_revision_invalidates_late_events_from_the_old_run() {
let (_directory, store) = fixtures::temp_store().await;
let first = store.begin_revision("conversation").await.unwrap();
let second = store.begin_revision("conversation").await.unwrap();
assert!(second > first);
assert!(!store
.revision_is_current("conversation", first)
.await
.unwrap());
assert!(store
.revision_is_current("conversation", second)
.await
.unwrap());
}
#[tokio::test]
async fn registry_shutdown_cancels_runs_and_closes_run_sse_outputs() {
let (_directory, store) = fixtures::temp_store().await;
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../prompt")
.as_path(),
)
.unwrap();
let registry = RunRegistry::new(
store,
Arc::new(fake_provider::FakeProvider::default()),
PromptCompiler::new(assets),
"test-model".into(),
);
let handle = registry.get_or_create("active-run").await.unwrap();
let mut output = handle.subscribe();
registry.shutdown().await;
assert!(handle.cancellation().is_cancelled());
let terminal = output.recv().await.expect("canceled EndStream");
let (flags, payload) = connect::decode_frames(&terminal).unwrap().pop().unwrap();
assert_eq!(flags, connect::END_STREAM_FLAG);
let payload: serde_json::Value = serde_json::from_slice(&payload).unwrap();
assert_eq!(payload["error"]["code"], "canceled");
assert_eq!(output.recv().await, None);
}
#[tokio::test]
async fn cancel_aborts_active_exec_before_canceled_end_stream() {
let (_directory, store) = fixtures::temp_store().await;
let provider = fake_provider::FakeProvider::default();
provider.push(vec![
ResponseEvent::ToolCallStart {
index: 0,
call_id: "call-1".into(),
name: "Read".into(),
},
ResponseEvent::ToolCallArgumentsDelta {
index: 0,
delta: "{\"path\":\"/tmp/a\"}".into(),
},
ResponseEvent::ToolCallEnd { index: 0 },
ResponseEvent::Done(FinishReason::ToolUse),
]);
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../prompt")
.as_path(),
)
.unwrap();
let registry = RunRegistry::new(
store,
Arc::new(provider),
PromptCompiler::new(assets),
"test-model".into(),
);
let handle = registry.get_or_create("cancel-request").await.unwrap();
let mut output = handle.subscribe();
handle
.command(RunCommand::Append {
seqno: 0,
message: Box::new(client_run()),
})
.await
.unwrap();
let mut append_seqno = 1;
let exec_id = loop {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
.await
.unwrap()
.unwrap();
let (_, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
let server = pb::AgentServerMessage::decode(payload).unwrap();
match server.message {
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
handle
.command(RunCommand::Append {
seqno: append_seqno,
message: Box::new(kv_ack(kv.id)),
})
.await
.unwrap();
append_seqno += 1;
}
Some(pb::agent_server_message::Message::ExecServerMessage(exec)) => break exec.id,
_ => {}
}
};
handle.cancel();
let mut saw_abort = false;
loop {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
.await
.unwrap()
.expect("RunSSE closed before canceled EndStream");
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
if flags & connect::END_STREAM_FLAG != 0 {
let json: serde_json::Value = serde_json::from_slice(&payload).unwrap();
assert_eq!(json["error"]["code"], "canceled");
assert!(saw_abort, "ExecServerAbort must precede canceled EndStream");
break;
}
let server = pb::AgentServerMessage::decode(payload).unwrap();
if let Some(pb::agent_server_message::Message::ExecServerControlMessage(control)) =
server.message
{
let Some(pb::exec_server_control_message::Message::Abort(abort)) = control.message
else {
panic!("expected ExecServerAbort")
};
assert_eq!(abort.id, exec_id);
saw_abort = true;
}
}
assert_eq!(output.recv().await, None);
}
fn client_run() -> pb::AgentClientMessage {
pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::RunRequest(
pb::AgentRunRequest {
action: Some(pb::ConversationAction {
action: Some(pb::conversation_action::Action::UserMessageAction(
pb::UserMessageAction {
user_message: Some(pb::UserMessage {
text: "read".into(),
message_id: "cancel-user".into(),
mode: pb::AgentMode::Agent as i32,
..Default::default()
}),
..Default::default()
},
)),
..Default::default()
}),
conversation_id: Some("cancel-conversation".into()),
run_id: Some("cancel-request".into()),
..Default::default()
},
)),
}
}
fn kv_ack(id: u32) -> pb::AgentClientMessage {
pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::KvClientMessage(
pb::KvClientMessage {
id,
message: Some(pb::kv_client_message::Message::SetBlobResult(
pb::SetBlobResult { error: None },
)),
},
)),
}
}
+181
View File
@@ -0,0 +1,181 @@
#[path = "support/fixtures.rs"]
mod fixtures;
use cursor_server::{
model::{CanonicalMessage, MessageContent, Origin, Role, ToolCallContent, ToolResultContent},
prompting::{project_messages, Mode, PromptAssets, PromptCompiler, ToolDefinition},
};
#[test]
fn projecting_an_append_only_context_preserves_the_complete_prefix() {
let first = vec![fixtures::user("u1", "one")];
let mut second = first.clone();
second.push(fixtures::user("u2", "two"));
let projected_first = project_messages(&first).unwrap();
let projected_second = project_messages(&second).unwrap();
assert_eq!(projected_first, projected_second[..projected_first.len()]);
}
#[test]
fn every_tool_result_is_projected_as_string_content() {
let object = serde_json::json!({"merge": false, "todos": []});
let messages = vec![
tool_result("object", object.clone()),
tool_result("string", serde_json::Value::String("plain text".into())),
];
let projected = project_messages(&messages).unwrap();
let object_text = projected[0]
.content
.as_str()
.expect("object ToolResult must be JSON-encoded into a string");
assert_eq!(
serde_json::from_str::<serde_json::Value>(object_text).unwrap(),
object
);
assert_eq!(projected[1].content.as_str(), Some("plain text"));
}
#[test]
fn assistant_text_and_thinking_remain_separate_during_projection() {
let messages = vec![CanonicalMessage {
message_id: "assistant".into(),
role: Role::Assistant,
origin: Origin::Assistant,
content: MessageContent::Assistant {
text: "visible answer".into(),
thinking: "private reasoning".into(),
model_call_id: Some("model-call".into()),
tool_calls: Vec::new(),
},
runtime_event_id: None,
}];
let projected = project_messages(&messages).unwrap();
assert_eq!(projected[0].content.as_str(), Some("visible answer"));
assert_eq!(projected[0].thinking.as_deref(), Some("private reasoning"));
}
#[test]
fn split_tool_pairs_reconstruct_the_original_provider_assistant_message() {
let messages = vec![
assistant_tool_pair(
"assistant-second",
"model-call",
1,
"call-second",
"visible answer",
"complete reasoning",
),
tool_result_with_call("result-second", "call-second", "second"),
assistant_tool_pair("assistant-first", "model-call", 0, "call-first", "", ""),
tool_result_with_call("result-first", "call-first", "first"),
];
let projected = project_messages(&messages).unwrap();
assert_eq!(projected.len(), 3);
assert_eq!(projected[0].role, "assistant");
assert_eq!(projected[0].thinking.as_deref(), Some("complete reasoning"));
let calls = projected[0].tool_calls.as_ref().unwrap();
assert_eq!(calls[0]["id"], "call-first");
assert_eq!(calls[1]["id"], "call-second");
assert_eq!(projected[1].tool_call_id.as_deref(), Some("call-first"));
assert_eq!(projected[2].tool_call_id.as_deref(), Some("call-second"));
}
#[test]
fn every_prompt_mode_loads_the_captured_tool_set() {
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../prompt")
.as_path(),
)
.unwrap();
assert_eq!(assets.mode(Mode::Agent).tools.len(), 20);
assert_eq!(assets.mode(Mode::Ask).tools.len(), 18);
assert_eq!(assets.mode(Mode::Plan).tools.len(), 16);
assert_eq!(assets.mode(Mode::Debug).tools.len(), 18);
assert_eq!(assets.mode(Mode::Multitask).tools.len(), 20);
assert_eq!(assets.mode(Mode::Subagent).tools.len(), 4);
}
#[test]
fn dynamic_mcp_tools_are_appended_after_the_stable_mode_tool_prefix() {
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../prompt")
.as_path(),
)
.unwrap();
let compiler = PromptCompiler::new(assets);
let messages = vec![fixtures::user("u1", "one")];
let base = compiler
.compile(Mode::Agent, "model", "call", &messages)
.unwrap();
let dynamic = compiler
.compile_with_dynamic_tools(
Mode::Agent,
"model",
"call",
&messages,
&[ToolDefinition {
name: "mcp_repo_lookup".into(),
description: "lookup".into(),
input_schema: serde_json::json!({"type": "object"}),
}],
)
.unwrap();
assert_eq!(base.tools, dynamic.tools[..base.tools.len()]);
assert_eq!(dynamic.tools.last().unwrap().name, "mcp_repo_lookup");
}
fn tool_result(id: &str, output: serde_json::Value) -> CanonicalMessage {
tool_result_with_call(id, &format!("call-{id}"), output)
}
fn tool_result_with_call(
id: &str,
call_id: &str,
output: impl Into<serde_json::Value>,
) -> CanonicalMessage {
CanonicalMessage {
message_id: id.into(),
role: Role::Tool,
origin: Origin::Tool,
content: MessageContent::ToolResult(ToolResultContent {
call_id: call_id.into(),
name: "Tool".into(),
output: output.into(),
is_error: false,
}),
runtime_event_id: None,
}
}
fn assistant_tool_pair(
id: &str,
model_call_id: &str,
index: usize,
call_id: &str,
text: &str,
thinking: &str,
) -> CanonicalMessage {
CanonicalMessage {
message_id: id.into(),
role: Role::Assistant,
origin: Origin::Assistant,
content: MessageContent::Assistant {
text: text.into(),
thinking: thinking.into(),
model_call_id: Some(model_call_id.into()),
tool_calls: vec![ToolCallContent {
index,
call_id: call_id.into(),
name: "Tool".into(),
arguments: serde_json::json!({}),
}],
},
runtime_event_id: None,
}
}
+27
View File
@@ -0,0 +1,27 @@
#[path = "support/fixtures.rs"]
mod fixtures;
use cursor_server::model::RuntimeEvent;
#[tokio::test]
async fn runtime_event_is_appended_exactly_once() {
let (_directory, store) = fixtures::temp_store().await;
let event = RuntimeEvent {
event_id: "branch:changed:7".into(),
text: "runtime state changed".into(),
};
assert!(store
.append_runtime_event_once("conversation", event.clone())
.await
.unwrap());
assert!(!store
.append_runtime_event_once("conversation", event)
.await
.unwrap());
let messages = store.load_messages("conversation").await.unwrap();
assert_eq!(messages.len(), 1);
assert_eq!(
messages[0].runtime_event_id.as_deref(),
Some("branch:changed:7")
);
}
@@ -0,0 +1,9 @@
use bytes::Bytes;
use cursor_server::{cursor::connect, Result};
use prost::Message;
pub fn decode_single<M: Message + Default>(frame: &Bytes) -> Result<M> {
let frames = connect::decode_frames(frame)?;
assert_eq!(frames.len(), 1);
Ok(M::decode(frames[0].1.clone())?)
}
@@ -0,0 +1,50 @@
#![allow(dead_code)]
use std::{
collections::VecDeque,
sync::{Arc, Mutex},
};
use cursor_server::{
prompting::ModelRequest,
provider::{Provider, ProviderStream, ResponseEvent},
Error,
};
use futures_util::stream;
use tokio_util::sync::CancellationToken;
type FakeResponse = Vec<Result<ResponseEvent, Error>>;
#[derive(Clone, Default)]
pub struct FakeProvider {
responses: Arc<Mutex<VecDeque<FakeResponse>>>,
requests: Arc<Mutex<Vec<ModelRequest>>>,
}
impl FakeProvider {
pub fn push(&self, events: Vec<ResponseEvent>) {
self.responses
.lock()
.unwrap()
.push_back(events.into_iter().map(Ok).collect());
}
pub fn push_error(&self, error: Error) {
self.responses.lock().unwrap().push_back(vec![Err(error)]);
}
pub fn requests(&self) -> Vec<ModelRequest> {
self.requests.lock().unwrap().clone()
}
}
impl Provider for FakeProvider {
fn stream(&self, request: ModelRequest, _cancellation: CancellationToken) -> ProviderStream {
self.requests.lock().unwrap().push(request);
let events = self
.responses
.lock()
.unwrap()
.pop_front()
.expect("fake response configured");
Box::pin(stream::iter(events))
}
}
+17
View File
@@ -0,0 +1,17 @@
#![allow(dead_code)]
use cursor_server::{
model::{CanonicalMessage, Origin, Role},
store::Store,
};
pub async fn temp_store() -> (tempfile::TempDir, Store) {
let directory = tempfile::tempdir().unwrap();
let url = format!("sqlite://{}", directory.path().join("test.db").display());
let store = Store::connect(&url).await.unwrap();
(directory, store)
}
pub fn user(id: &str, text: &str) -> CanonicalMessage {
CanonicalMessage::text(id, Role::User, Origin::User, text)
}
+158
View File
@@ -0,0 +1,158 @@
#[path = "support/fake_provider.rs"]
mod fake_provider;
#[path = "support/fixtures.rs"]
mod fixtures;
use std::sync::Arc;
use cursor_server::{
cursor::{connect, proto::agent::v1 as pb},
prompting::{PromptAssets, PromptCompiler},
provider::{FinishReason, ResponseEvent},
run::{RunCommand, RunRegistry},
};
use prost::Message;
#[tokio::test]
async fn text_turn_runs_from_bidi_request_through_checkpoint_and_end_stream() {
let (_directory, store) = fixtures::temp_store().await;
let provider = fake_provider::FakeProvider::default();
provider.push(vec![
ResponseEvent::Start {
model_call_id: "ignored".into(),
},
ResponseEvent::ThinkingStart,
ResponseEvent::ThinkingDelta("reason".into()),
ResponseEvent::ThinkingEnd,
ResponseEvent::TextStart,
ResponseEvent::TextDelta("hello".into()),
ResponseEvent::TextEnd,
ResponseEvent::Done(FinishReason::Stop),
]);
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../prompt")
.as_path(),
)
.unwrap();
let registry = RunRegistry::new(
store.clone(),
Arc::new(provider.clone()),
PromptCompiler::new(assets),
"test-model".into(),
);
let handle = registry.get_or_create("request").await.unwrap();
let mut output = handle.subscribe();
handle
.command(RunCommand::Append {
seqno: 0,
message: Box::new(client_run("request", "conversation", "hello")),
})
.await
.unwrap();
let mut append_seqno = 1;
let mut text = String::new();
let mut thinking = String::new();
let mut thinking_duration_ms = None;
let mut saw_turn_ended = false;
let mut checkpoints = 0;
loop {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
.await
.unwrap()
.unwrap();
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
if flags & connect::END_STREAM_FLAG != 0 {
break;
}
let server = pb::AgentServerMessage::decode(payload).unwrap();
match server.message {
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
handle
.command(RunCommand::Append {
seqno: append_seqno,
message: Box::new(kv_ack(kv.id)),
})
.await
.unwrap();
append_seqno += 1;
}
Some(pb::agent_server_message::Message::InteractionUpdate(update)) => {
match update.message {
Some(pb::interaction_update::Message::TextDelta(delta)) => {
text.push_str(&delta.text)
}
Some(pb::interaction_update::Message::ThinkingDelta(delta)) => {
assert_eq!(
delta.thinking_style,
Some(pb::ThinkingStyle::Default as i32)
);
thinking.push_str(&delta.text);
}
Some(pb::interaction_update::Message::ThinkingCompleted(completed)) => {
thinking_duration_ms = Some(completed.thinking_duration_ms)
}
Some(pb::interaction_update::Message::TurnEnded(_)) => saw_turn_ended = true,
_ => {}
}
}
Some(pb::agent_server_message::Message::ConversationCheckpointUpdate(_)) => {
checkpoints += 1
}
_ => {}
}
}
assert_eq!(text, "hello");
assert_eq!(thinking, "reason");
assert!(thinking_duration_ms.is_some_and(|duration| duration >= 1));
assert!(saw_turn_ended);
assert_eq!(checkpoints, 2, "final checkpoint is intentionally repeated");
assert_eq!(provider.requests().len(), 1);
assert!(store
.load_messages("conversation")
.await
.unwrap()
.iter()
.any(|message| message.message_id == "user"));
}
fn client_run(request_id: &str, conversation_id: &str, text: &str) -> pb::AgentClientMessage {
let user = pb::UserMessage {
text: text.into(),
message_id: "user".into(),
mode: pb::AgentMode::Agent as i32,
..Default::default()
};
pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::RunRequest(
pb::AgentRunRequest {
action: Some(pb::ConversationAction {
action: Some(pb::conversation_action::Action::UserMessageAction(
pb::UserMessageAction {
user_message: Some(user),
..Default::default()
},
)),
..Default::default()
}),
conversation_id: Some(conversation_id.into()),
run_id: Some(request_id.into()),
..Default::default()
},
)),
}
}
fn kv_ack(id: u32) -> pb::AgentClientMessage {
pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::KvClientMessage(
pb::KvClientMessage {
id,
message: Some(pb::kv_client_message::Message::SetBlobResult(
pb::SetBlobResult { error: None },
)),
},
)),
}
}
+521
View File
@@ -0,0 +1,521 @@
#[path = "support/fake_provider.rs"]
mod fake_provider;
#[path = "support/fixtures.rs"]
mod fixtures;
use std::sync::Arc;
use cursor_server::{
cursor::{
connect, exec,
pending::{ExecContext, PendingExecRegistry},
proto::agent::v1 as pb,
},
model::{MessageContent, ToolCall},
prompting::{PromptAssets, PromptCompiler},
provider::{FinishReason, ResponseEvent},
run::{RunCommand, RunRegistry},
};
use prost::Message;
use serde_json::json;
fn call(id: &str, name: &str) -> ToolCall {
ToolCall {
index: 0,
call_id: id.into(),
model_call_id: "model:0".into(),
name: name.into(),
arguments_text: "{}".into(),
arguments: json!({}),
}
}
fn exec_context() -> ExecContext {
ExecContext {
conversation_id: "conversation".into(),
terminals_folder: "/tmp/terminals".into(),
admin_command_denylist: Vec::new(),
}
}
#[test]
fn dynamic_mcp_call_routes_to_the_captured_exec_message() {
let call = ToolCall {
index: 0,
call_id: "mcp-call".into(),
model_call_id: "model:0".into(),
name: "mcp_repo_lookup".into(),
arguments_text: "{\"query\":\"x\"}".into(),
arguments: json!({"query": "x"}),
};
let definition = pb::McpToolDefinition {
name: "mcp_repo_lookup".into(),
provider_identifier: "repo".into(),
tool_name: "lookup".into(),
description: "lookup".into(),
input_schema: None,
input_schema_json: None,
};
let message = cursor_server::cursor::exec::mcp_request(7, &call, &definition).unwrap();
let Some(pb::agent_server_message::Message::ExecServerMessage(exec)) = message.message else {
panic!("expected ExecServerMessage")
};
let Some(pb::exec_server_message::Message::McpArgs(args)) = exec.message else {
panic!("expected McpArgs")
};
assert_eq!(exec.exec_id, "mcp-call");
assert_eq!(args.provider_identifier, "repo");
assert_eq!(args.tool_name, "lookup");
assert_eq!(
args.args["query"].kind,
Some(prost_types::value::Kind::StringValue("x".into()))
);
}
#[tokio::test]
async fn shell_uses_background_timeout_and_preserves_stream_identity() {
let mut shell = call("call-shell", "Shell");
shell.arguments = json!({
"command": "python3 -m http.server 8000",
"working_directory": "/tmp/project",
"block_until_ms": 3000,
"description": "Start HTTP server"
});
let context = exec_context();
let request = exec::request(7, &shell, &context).unwrap();
let Some(pb::agent_server_message::Message::ExecServerMessage(request)) = request.message
else {
panic!("expected ExecServerMessage")
};
assert_eq!(request.accept_hook_additional_contexts, Some(true));
let Some(pb::exec_server_message::Message::ShellStreamArgs(args)) = request.message else {
panic!("expected ShellArgs")
};
assert_eq!(args.timeout, 3000);
assert_eq!(
args.timeout_behavior,
pb::TimeoutBehavior::Background as i32
);
assert_eq!(args.hard_timeout, Some(86_400_000));
assert_eq!(args.description.as_deref(), Some("Start HTTP server"));
assert!(args.close_stdin);
assert_eq!(args.conversation_id.as_deref(), Some("conversation"));
assert_eq!(args.file_output_threshold_bytes, Some(40_000));
let pending = PendingExecRegistry::default();
let id = pending.reserve(&shell, &context).await.unwrap();
let delta = exec::client_event(
&pb::ExecClientMessage {
id,
message: Some(pb::exec_client_message::Message::ShellStream(
pb::ShellStream {
event: Some(pb::shell_stream::Event::Stdout(pb::ShellStreamStdout {
data: "Serving HTTP on port 8000\n".into(),
})),
},
)),
..Default::default()
},
&pending,
)
.await
.unwrap();
let exec::ClientExecEvent::Delta(delta) = delta else {
panic!("expected Shell stdout delta")
};
let Some(pb::agent_server_message::Message::InteractionUpdate(delta)) = delta.message else {
panic!("expected InteractionUpdate")
};
let Some(pb::interaction_update::Message::ToolCallDelta(delta)) = delta.message else {
panic!("expected ToolCallDelta")
};
assert_eq!(delta.call_id, "call-shell");
assert_eq!(delta.model_call_id, "model:0");
let Some(pb::tool_call_delta::Delta::ShellToolCallDelta(shell_delta)) =
delta.tool_call_delta.and_then(|delta| delta.delta)
else {
panic!("expected ShellToolCallDelta")
};
let Some(pb::shell_tool_call_delta::Delta::Stdout(stdout)) = shell_delta.delta else {
panic!("expected stdout")
};
assert_eq!(stdout.content, "Serving HTTP on port 8000\n");
let completion = exec::client_event(
&pb::ExecClientMessage {
id,
message: Some(pb::exec_client_message::Message::ShellStream(
pb::ShellStream {
event: Some(pb::shell_stream::Event::Backgrounded(
pb::ShellStreamBackgrounded {
shell_id: 42,
command: "python3 -m http.server 8000".into(),
working_directory: "/tmp/project".into(),
pid: Some(1234),
ms_to_wait: Some(3000),
reason: Some(pb::ShellBackgroundReason::Timeout as i32),
},
)),
},
)),
..Default::default()
},
&pending,
)
.await
.unwrap();
let exec::ClientExecEvent::Completed(completion) = completion else {
panic!("expected background completion")
};
assert_eq!(
completion.result().output.as_str(),
Some(
"shell running in background shell_id=42 pid=1234 terminals_folder=/tmp/terminals\nServing HTTP on port 8000\n"
)
);
let Some(pb::tool_call::Tool::ShellToolCall(tool)) = &completion.tool_call().tool else {
panic!("expected ShellToolCall")
};
let result = tool.result.as_ref().expect("background ShellResult");
assert_eq!(result.is_background, Some(true));
assert_eq!(result.terminals_folder.as_deref(), Some("/tmp/terminals"));
assert_eq!(result.pid, Some(1234));
}
#[tokio::test]
async fn exec_ids_are_monotonic_and_released_ids_are_not_reused() {
let pending = PendingExecRegistry::default();
let first = pending
.reserve(&call("call-1", "Read"), &exec_context())
.await
.unwrap();
assert_eq!(first, 1);
assert_eq!(
pending.call(first).await.map(|call| call.call_id),
Some("call-1".into())
);
pending.discard(first).await;
assert!(pending.call(first).await.is_none());
let second = pending
.reserve(&call("call-2", "Read"), &exec_context())
.await
.unwrap();
assert_eq!(second, 2, "released Exec ids must not be reused in one Run");
}
#[tokio::test]
async fn empty_exec_client_message_is_not_a_terminal_result() {
let pending = PendingExecRegistry::default();
let id = pending
.reserve(&call("call-1", "Read"), &exec_context())
.await
.unwrap();
let event = exec::client_event(
&pb::ExecClientMessage {
id,
message: None,
..Default::default()
},
&pending,
)
.await
.unwrap();
assert!(matches!(event, exec::ClientExecEvent::Pending));
assert_eq!(
pending.call(id).await.map(|call| call.call_id),
Some("call-1".into())
);
}
#[tokio::test]
async fn tool_success_is_not_inferred_from_debug_text() {
let pending = PendingExecRegistry::default();
let mut write = call("call-1", "Write");
write.arguments = json!({"path": "/tmp/a", "contents": "x"});
let id = pending.reserve(&write, &exec_context()).await.unwrap();
let event = exec::client_event(
&pb::ExecClientMessage {
id,
message: Some(pb::exec_client_message::Message::WriteResult(
pb::WriteResult {
result: Some(pb::write_result::Result::Success(pb::WriteSuccess {
path: "/tmp/a".into(),
file_content_after_write: Some("enum Error { Example }".into()),
..Default::default()
})),
},
)),
..Default::default()
},
&pending,
)
.await
.unwrap();
let exec::ClientExecEvent::Completed(completion) = event else {
panic!("expected terminal write result")
};
assert!(!completion.result().is_error);
assert!(matches!(
completion.tool_call().tool,
Some(pb::tool_call::Tool::EditToolCall(_))
));
}
#[tokio::test]
async fn an_exec_result_must_match_the_reserved_tool() {
let pending = PendingExecRegistry::default();
let id = pending
.reserve(&call("call-1", "Read"), &exec_context())
.await
.unwrap();
let result = exec::client_event(
&pb::ExecClientMessage {
id,
message: Some(pb::exec_client_message::Message::WriteResult(
pb::WriteResult {
result: Some(pb::write_result::Result::Success(pb::WriteSuccess {
path: "/tmp/a".into(),
..Default::default()
})),
},
)),
..Default::default()
},
&pending,
)
.await;
let Err(error) = result else {
panic!("mismatched result must fail")
};
assert!(error
.to_string()
.contains("unexpected Exec result for tool Read"));
assert!(pending.call(id).await.is_none());
}
#[tokio::test]
async fn provider_tool_use_waits_for_client_result_then_calls_provider_again() {
let (directory, store) = fixtures::temp_store().await;
let provider = fake_provider::FakeProvider::default();
provider.push(vec![
ResponseEvent::Start {
model_call_id: "ignored".into(),
},
ResponseEvent::ToolCallStart {
index: 0,
call_id: "call-1".into(),
name: "Read".into(),
},
ResponseEvent::ToolCallArgumentsDelta {
index: 0,
delta: "{\"path\":\"/tmp/a\"}".into(),
},
ResponseEvent::ToolCallEnd { index: 0 },
ResponseEvent::Done(FinishReason::ToolUse),
]);
provider.push(vec![
ResponseEvent::Start {
model_call_id: "ignored".into(),
},
ResponseEvent::TextStart,
ResponseEvent::TextDelta("done".into()),
ResponseEvent::TextEnd,
ResponseEvent::Done(FinishReason::Stop),
]);
let assets = PromptAssets::load(
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../prompt")
.as_path(),
)
.unwrap();
let registry = RunRegistry::new(
store.clone(),
Arc::new(provider.clone()),
PromptCompiler::new(assets),
"test-model".into(),
);
let handle = registry.get_or_create("tool-request").await.unwrap();
let mut output = handle.subscribe();
handle
.command(RunCommand::Append {
seqno: 0,
message: Box::new(client_run()),
})
.await
.unwrap();
let mut seqno = 1;
let mut saw_exec = false;
let mut saw_typed_completion = false;
loop {
let frame = tokio::time::timeout(std::time::Duration::from_secs(5), output.recv())
.await
.unwrap()
.unwrap();
let (flags, payload) = connect::decode_frames(&frame).unwrap().pop().unwrap();
if flags & connect::END_STREAM_FLAG != 0 {
assert_eq!(
serde_json::from_slice::<serde_json::Value>(&payload).unwrap(),
json!({})
);
break;
}
let server = pb::AgentServerMessage::decode(payload).unwrap();
match server.message {
Some(pb::agent_server_message::Message::KvServerMessage(kv)) => {
handle
.command(RunCommand::Append {
seqno,
message: Box::new(kv_ack(kv.id)),
})
.await
.unwrap();
seqno += 1;
}
Some(pb::agent_server_message::Message::ExecServerMessage(exec)) => {
saw_exec = true;
let exec_id = exec.id;
handle
.command(RunCommand::Append {
seqno,
message: Box::new(pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::ExecClientMessage(
pb::ExecClientMessage {
id: exec_id,
exec_id: String::new(),
message: Some(pb::exec_client_message::Message::ReadResult(
pb::ReadResult {
result: Some(pb::read_result::Result::Success(
pb::ReadSuccess {
path: "/tmp/a".into(),
total_lines: 1,
file_size: 1,
output: Some(
pb::read_success::Output::Content(
"x".into(),
),
),
..Default::default()
},
)),
},
)),
..Default::default()
},
)),
}),
})
.await
.unwrap();
seqno += 1;
handle
.command(RunCommand::Append {
seqno,
message: Box::new(pb::AgentClientMessage {
message: Some(
pb::agent_client_message::Message::ExecClientControlMessage(
pb::ExecClientControlMessage {
message: Some(
pb::exec_client_control_message::Message::StreamClose(
pb::ExecClientStreamClose { id: exec_id },
),
),
},
),
),
}),
})
.await
.unwrap();
seqno += 1;
}
Some(pb::agent_server_message::Message::InteractionUpdate(update)) => {
if let Some(pb::interaction_update::Message::ToolCallCompleted(completed)) =
update.message
{
let tool_call = completed.tool_call.expect("completed ToolCall");
assert!(tool_call.started_at_ms.unwrap_or_default() > 1);
assert!(tool_call.completed_at_ms.unwrap_or_default() > 1);
assert!(tool_call.completed_at_ms >= tool_call.started_at_ms);
let Some(pb::tool_call::Tool::ReadToolCall(read)) = tool_call.tool else {
panic!("expected completed ReadToolCall")
};
let result = read.result.expect("typed ReadToolResult");
assert!(matches!(
result.result,
Some(pb::read_tool_result::Result::Success(_))
));
saw_typed_completion = true;
}
}
_ => {}
}
}
assert!(saw_exec);
assert!(saw_typed_completion);
assert_eq!(provider.requests().len(), 2);
let database = sqlx::SqlitePool::connect(&format!(
"sqlite://{}",
directory.path().join("test.db").display()
))
.await
.unwrap();
let provider_call_index: i64 =
sqlx::query_scalar("SELECT provider_call_index FROM runs WHERE request_id = ?")
.bind("tool-request")
.fetch_one(&database)
.await
.unwrap();
assert_eq!(provider_call_index, 1);
let messages = store.load_messages("tool-conversation").await.unwrap();
let result_position = messages
.iter()
.position(|message| matches!(message.content, MessageContent::ToolResult(_)))
.expect("tool result persisted");
let MessageContent::Assistant { tool_calls, .. } = &messages[result_position - 1].content
else {
panic!("tool result must immediately follow its assistant tool call")
};
assert_eq!(tool_calls.len(), 1);
assert_eq!(tool_calls[0].call_id, "call-1");
}
fn client_run() -> pb::AgentClientMessage {
let user = pb::UserMessage {
text: "read it".into(),
message_id: "user".into(),
mode: pb::AgentMode::Agent as i32,
..Default::default()
};
pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::RunRequest(
pb::AgentRunRequest {
action: Some(pb::ConversationAction {
action: Some(pb::conversation_action::Action::UserMessageAction(
pb::UserMessageAction {
user_message: Some(user),
..Default::default()
},
)),
..Default::default()
}),
conversation_id: Some("tool-conversation".into()),
run_id: Some("tool-request".into()),
..Default::default()
},
)),
}
}
fn kv_ack(id: u32) -> pb::AgentClientMessage {
pb::AgentClientMessage {
message: Some(pb::agent_client_message::Message::KvClientMessage(
pb::KvClientMessage {
id,
message: Some(pb::kv_client_message::Message::SetBlobResult(
pb::SetBlobResult { error: None },
)),
},
)),
}
}