mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-18 03:57:06 +08:00
all tools
This commit is contained in:
Generated
+2876
File diff suppressed because it is too large
Load Diff
@@ -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"] }
|
||||
@@ -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 保持一致。
|
||||
@@ -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.
@@ -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);
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
),
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {})
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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) }
|
||||
}
|
||||
@@ -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(®istry, &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(®istry, 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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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(_))
|
||||
));
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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};
|
||||
@@ -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(())
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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::*;
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
@@ -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())),
|
||||
})
|
||||
}
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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::*;
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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",
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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(())
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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"));
|
||||
}
|
||||
@@ -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 },
|
||||
)),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 },
|
||||
)),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
@@ -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 },
|
||||
)),
|
||||
},
|
||||
)),
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user