mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-08-17 19:47:10 +08:00
265 lines
8.4 KiB
Rust
265 lines
8.4 KiB
Rust
use std::collections::HashSet;
|
|
|
|
use base64::{engine::general_purpose::STANDARD, Engine};
|
|
use serde_json::{json, Map, Value};
|
|
|
|
use crate::{
|
|
model::{
|
|
project_messages, CanonicalMessage, ContentPart, ProjectedContent, ProjectedMessage, Role,
|
|
ToolCall, ToolCallContent, ToolRoundAssistant,
|
|
},
|
|
Error, Result,
|
|
};
|
|
|
|
use super::REPLAY_ENVELOPE_PREFIX;
|
|
|
|
pub fn stable_messages(
|
|
instructions: &str,
|
|
messages: &[CanonicalMessage],
|
|
model: &str,
|
|
) -> Result<Vec<Vec<u8>>> {
|
|
let mut projected = project_messages(messages)?;
|
|
if !instructions.is_empty() {
|
|
projected.insert(
|
|
0,
|
|
ProjectedMessage {
|
|
message_id: "system".into(),
|
|
role: Role::System,
|
|
content: ProjectedContent::Parts(vec![ContentPart::Text {
|
|
text: instructions.into(),
|
|
}]),
|
|
},
|
|
);
|
|
}
|
|
projected
|
|
.iter()
|
|
.map(|message| serde_json::to_vec(&wire_message(message, model, None)?).map_err(Into::into))
|
|
.collect::<std::result::Result<_, _>>()
|
|
}
|
|
|
|
pub fn staged_tool_round(
|
|
assistant: &ToolRoundAssistant,
|
|
calls: &[ToolCall],
|
|
model: &str,
|
|
allowed_tools: &[String],
|
|
dynamic_tools: &HashSet<String>,
|
|
started_at_ms: u64,
|
|
) -> Result<String> {
|
|
let message = ProjectedMessage {
|
|
message_id: assistant.model_call_id.clone(),
|
|
role: Role::Assistant,
|
|
content: ProjectedContent::Assistant {
|
|
text: assistant.text.clone(),
|
|
thinking: assistant.thinking.clone(),
|
|
replay_state: assistant.replay_state.clone(),
|
|
calls: calls
|
|
.iter()
|
|
.map(|call| ToolCallContent {
|
|
index: call.index,
|
|
call_id: call.call_id.clone(),
|
|
name: call.name.clone(),
|
|
arguments: call.arguments.clone(),
|
|
})
|
|
.collect(),
|
|
},
|
|
};
|
|
Ok(serde_json::to_string(&wire_message(
|
|
&message,
|
|
model,
|
|
Some(PendingContext {
|
|
allowed_tools,
|
|
dynamic_tools,
|
|
started_at_ms,
|
|
}),
|
|
)?)?)
|
|
}
|
|
|
|
pub fn staged_final(
|
|
message: &CanonicalMessage,
|
|
model: &str,
|
|
allowed_tools: &[String],
|
|
dynamic_tools: &HashSet<String>,
|
|
started_at_ms: u64,
|
|
) -> Result<String> {
|
|
let projected = project_messages(std::slice::from_ref(message))?;
|
|
let assistant = projected
|
|
.first()
|
|
.filter(|message| message.role == Role::Assistant)
|
|
.ok_or_else(|| {
|
|
Error::Protocol("final checkpoint stage is not an assistant message".into())
|
|
})?;
|
|
Ok(serde_json::to_string(&wire_message(
|
|
assistant,
|
|
model,
|
|
Some(PendingContext {
|
|
allowed_tools,
|
|
dynamic_tools,
|
|
started_at_ms,
|
|
}),
|
|
)?)?)
|
|
}
|
|
|
|
#[derive(Clone, Copy)]
|
|
pub(super) struct PendingContext<'a> {
|
|
allowed_tools: &'a [String],
|
|
dynamic_tools: &'a HashSet<String>,
|
|
started_at_ms: u64,
|
|
}
|
|
|
|
pub(super) fn wire_message(
|
|
message: &ProjectedMessage,
|
|
model: &str,
|
|
pending: Option<PendingContext<'_>>,
|
|
) -> Result<Value> {
|
|
let mut root = Map::new();
|
|
root.insert(
|
|
"role".into(),
|
|
Value::String(role_name(&message.role).into()),
|
|
);
|
|
root.insert("content".into(), wire_content(&message.content, model)?);
|
|
root.insert("id".into(), Value::String(wire_message_id(message)));
|
|
if let ProjectedContent::Assistant { calls, .. } = &message.content {
|
|
let mut cursor = Map::new();
|
|
if let Some(pending) = pending {
|
|
cursor.insert(
|
|
"pendingToolCallStartedAtMs".into(),
|
|
json!(pending.started_at_ms),
|
|
);
|
|
cursor.insert(
|
|
"pendingToolExecutionContracts".into(),
|
|
Value::Object(
|
|
calls
|
|
.iter()
|
|
.map(|call| {
|
|
(
|
|
call.call_id.clone(),
|
|
json!({
|
|
"toolCallId": call.call_id,
|
|
"outerToolName": call.name,
|
|
"toolIdentifier": tool_identifier(&call.name, pending.dynamic_tools),
|
|
"isDynamic": pending.dynamic_tools.contains(&call.name),
|
|
"allowedToolNames": pending.allowed_tools,
|
|
}),
|
|
)
|
|
})
|
|
.collect(),
|
|
),
|
|
);
|
|
}
|
|
if !cursor.is_empty() {
|
|
root.insert("providerOptions".into(), json!({"cursor": cursor}));
|
|
}
|
|
}
|
|
Ok(Value::Object(root))
|
|
}
|
|
|
|
fn tool_identifier(name: &str, dynamic_tools: &HashSet<String>) -> String {
|
|
if dynamic_tools.contains(name) {
|
|
return name.into();
|
|
}
|
|
match name {
|
|
"AwaitShell" => "AWAIT".into(),
|
|
"CallMcpTool" => "MCP".into(),
|
|
"CreatePlan" => "CREATE_PLAN_V2".into(),
|
|
"UpdateCurrentStep" => "COMMUNICATE_UPDATE".into(),
|
|
_ => name
|
|
.chars()
|
|
.enumerate()
|
|
.fold(String::new(), |mut value, (index, character)| {
|
|
if index > 0 && character.is_ascii_uppercase() {
|
|
value.push('_');
|
|
}
|
|
value.push(character.to_ascii_uppercase());
|
|
value
|
|
}),
|
|
}
|
|
}
|
|
|
|
fn wire_message_id(message: &ProjectedMessage) -> String {
|
|
match &message.content {
|
|
ProjectedContent::Assistant { .. } => "1".into(),
|
|
ProjectedContent::ToolResult(result) => result.call_id.clone(),
|
|
ProjectedContent::Parts(_) => message.message_id.clone(),
|
|
}
|
|
}
|
|
|
|
fn wire_content(content: &ProjectedContent, model: &str) -> Result<Value> {
|
|
Ok(match content {
|
|
ProjectedContent::Parts(parts) => Value::Array(
|
|
parts
|
|
.iter()
|
|
.map(|part| match part {
|
|
ContentPart::Text { text } => json!({"type":"text", "text":text}),
|
|
ContentPart::Image { mime_type, data } => json!({
|
|
"type":"image",
|
|
"data": STANDARD.encode(data),
|
|
"mimeType": mime_type,
|
|
}),
|
|
})
|
|
.collect(),
|
|
),
|
|
ProjectedContent::Assistant {
|
|
text,
|
|
thinking,
|
|
replay_state,
|
|
calls,
|
|
} => {
|
|
let mut parts = Vec::new();
|
|
if !thinking.is_empty() || replay_state.is_some() {
|
|
let mut reasoning = json!({
|
|
"type": "reasoning",
|
|
"text": thinking,
|
|
"providerOptions": {"cursor": {"modelName": model}},
|
|
});
|
|
if let Some(replay_state) = replay_state {
|
|
reasoning["signature"] = Value::String(encode_replay_state(replay_state)?);
|
|
}
|
|
parts.push(reasoning);
|
|
}
|
|
if !text.is_empty() {
|
|
parts.push(json!({"type":"text", "text":text}));
|
|
}
|
|
parts.extend(calls.iter().map(|call| {
|
|
json!({
|
|
"type": "tool-call",
|
|
"toolCallId": call.call_id,
|
|
"toolName": call.name,
|
|
"args": call.arguments,
|
|
})
|
|
}));
|
|
Value::Array(parts)
|
|
}
|
|
ProjectedContent::ToolResult(result) => json!([{
|
|
"type": "tool-result",
|
|
"toolCallId": result.call_id,
|
|
"toolName": result.name,
|
|
"result": result.content,
|
|
"experimental_content": [{"type":"text", "text":result.content}],
|
|
"isError": result.is_error,
|
|
}]),
|
|
})
|
|
}
|
|
|
|
fn encode_replay_state(replay_state: &crate::model::ProviderReplayState) -> Result<String> {
|
|
if replay_state.provider_kind == "cursor_opaque" {
|
|
return replay_state
|
|
.value
|
|
.as_str()
|
|
.map(str::to_string)
|
|
.ok_or_else(|| Error::Protocol("Cursor opaque replay state is not a string".into()));
|
|
}
|
|
Ok(format!(
|
|
"{REPLAY_ENVELOPE_PREFIX}{}",
|
|
STANDARD.encode(serde_json::to_vec(replay_state)?)
|
|
))
|
|
}
|
|
|
|
fn role_name(role: &Role) -> &'static str {
|
|
match role {
|
|
Role::System => "system",
|
|
Role::User => "user",
|
|
Role::Assistant => "assistant",
|
|
Role::Tool => "tool",
|
|
}
|
|
}
|