Files
cursor-byok/server/src/plugin/wire.rs
T
leokun d83e14af9a refactor: remove retry_count from ProviderConfig and enhance error handling in tool execution
- Removed the `retry_count` field from `ProviderConfig` as it is no longer needed.
- Introduced `argument_error` field in `ToolCall` to capture errors related to tool arguments.
- Updated various components to handle argument errors more gracefully, including in the `ToolDispatcher` and `ConversationOutput`.
- Enhanced tests to validate the new error handling and ensure proper functionality of tool calls.
2026-09-01 10:14:53 +08:00

301 lines
11 KiB
Rust

//! Translates between core model types and the plugin SDK wire contract.
use base64::{engine::general_purpose::STANDARD, Engine};
use crate::{
model::{
ContentPart, ModelInvocation, ModelLatency, ProjectedContent, ProjectedMessage,
ProviderReplayState, Role, Usage,
},
provider::{FinishReason, ModelEvent},
Error, Result,
};
/// 把一次核心模型调用投影成 SDK 的 LlmRequest。
pub fn llm_request(invocation: &ModelInvocation) -> Result<serde_json::Value> {
let request = &invocation.request;
let messages = request
.history
.iter()
.map(wire_message)
.collect::<Result<Vec<_>>>()?;
Ok(serde_json::json!({
"instructions": request.prompt.instructions,
"messages": messages,
"tools": request.prompt.tools.iter().map(|tool| serde_json::json!({
"name": tool.name,
"description": tool.description,
"parameters": tool.parameters,
})).collect::<Vec<_>>(),
"reasoning": {
"enabled": request.model.reasoning.enabled,
"effort": request.model.reasoning.effort,
},
"latency": match request.model.latency {
ModelLatency::Fast => "fast",
_ => "standard",
},
"maxOutputTokens": request.model.max_output_tokens,
"cacheKey": invocation.conversation_id,
}))
}
fn wire_message(message: &ProjectedMessage) -> Result<serde_json::Value> {
match &message.content {
ProjectedContent::Parts(parts) => match message.role {
Role::System | Role::User => Ok(serde_json::json!({
"role": if message.role == Role::System { "system" } else { "user" },
"content": wire_parts(parts),
})),
// 纯文本 assistant 历史消息投影成无工具调用的 assistant。
Role::Assistant => Ok(serde_json::json!({
"role": "assistant",
"text": joined_text(parts),
"thinking": "",
"replayState": serde_json::Value::Null,
"toolCalls": [],
})),
Role::Tool => Err(Error::Protocol(
"tool messages must carry a tool result".into(),
)),
},
ProjectedContent::Assistant {
text,
thinking,
replay_state,
calls,
} => Ok(serde_json::json!({
"role": "assistant",
"text": text,
"thinking": thinking,
"replayState": replay_state.as_ref().map(|state| serde_json::json!({
"providerKind": state.provider_kind,
"value": state.value,
})),
"toolCalls": calls.iter().map(|call| serde_json::json!({
"index": call.index,
"callId": call.call_id,
"name": call.name,
"arguments": call.arguments,
})).collect::<Vec<_>>(),
})),
ProjectedContent::ToolResult(result) => Ok(serde_json::json!({
"role": "tool",
"callId": result.call_id,
"name": result.name,
"content": result.content,
"isError": result.is_error,
"parts": wire_parts(&result.provider_parts),
})),
}
}
fn wire_parts(parts: &[ContentPart]) -> Vec<serde_json::Value> {
parts
.iter()
.map(|part| match part {
ContentPart::Text { text } => serde_json::json!({ "type": "text", "text": text }),
ContentPart::Image { mime_type, data } => serde_json::json!({
"type": "image",
"mediaType": mime_type,
"dataBase64": STANDARD.encode(data),
}),
})
.collect()
}
fn joined_text(parts: &[ContentPart]) -> String {
parts
.iter()
.filter_map(|part| match part {
ContentPart::Text { text } => Some(text.as_str()),
ContentPart::Image { .. } => None,
})
.collect()
}
/// 把插件发出的标准化事件解析为核心 ModelEvent。
pub fn model_event(value: &serde_json::Value) -> Result<ModelEvent> {
let kind = value
.get("type")
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::Protocol("plugin model event requires type".into()))?;
let event = match kind {
"text-start" => ModelEvent::TextStart,
"text-delta" => ModelEvent::TextDelta(required_str(value, "text")?.to_owned()),
"text-end" => ModelEvent::TextEnd,
"thinking-start" => ModelEvent::ThinkingStart,
"thinking-delta" => ModelEvent::ThinkingDelta(required_str(value, "text")?.to_owned()),
"thinking-end" => ModelEvent::ThinkingEnd,
"tool-call-start" => ModelEvent::ToolCallStart {
index: required_index(value)?,
call_id: required_str(value, "callId")?.to_owned(),
name: required_str(value, "name")?.to_owned(),
},
"tool-call-arguments-delta" => ModelEvent::ToolCallArgumentsDelta {
index: required_index(value)?,
delta: required_str(value, "delta")?.to_owned(),
},
"tool-call-end" => ModelEvent::ToolCallEnd {
index: required_index(value)?,
},
"replay-state" => ModelEvent::ProviderReplayState(ProviderReplayState {
provider_kind: required_str(value, "providerKind")?.to_owned(),
value: value.get("value").cloned().unwrap_or_default(),
}),
"usage" => {
let usage = value
.get("usage")
.ok_or_else(|| Error::Protocol("plugin usage event requires usage".into()))?;
let tokens = |name: &str| usage.get(name).and_then(serde_json::Value::as_u64);
let input_tokens = tokens("inputTokens");
ModelEvent::Usage(Usage {
input_tokens,
context_input_tokens: input_tokens,
output_tokens: tokens("outputTokens"),
total_tokens: tokens("totalTokens"),
cache_read_tokens: tokens("cacheReadTokens"),
cache_write_tokens: tokens("cacheWriteTokens"),
reasoning_tokens: tokens("reasoningTokens"),
})
}
"done" => ModelEvent::Done(match required_str(value, "reason")? {
"stop" => FinishReason::Stop,
"length" => FinishReason::Length,
"tool-use" => FinishReason::ToolUse,
reason => {
return Err(Error::Protocol(format!(
"unknown plugin finish reason: {reason}"
)))
}
}),
kind => {
return Err(Error::Protocol(format!(
"unknown plugin model event: {kind}"
)))
}
};
Ok(event)
}
fn required_str<'a>(value: &'a serde_json::Value, key: &str) -> Result<&'a str> {
value
.get(key)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| Error::Protocol(format!("plugin model event requires string '{key}'")))
}
fn required_index(value: &serde_json::Value) -> Result<usize> {
value
.get("index")
.and_then(serde_json::Value::as_u64)
.map(|index| index as usize)
.ok_or_else(|| Error::Protocol("plugin model event requires index".into()))
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::{
ModelRequest, ModelSpec, ProjectedContent, PromptSpec, ToolCallContent, ToolResultContent,
};
#[test]
fn projects_history_into_wire_messages() {
let invocation = ModelInvocation {
call_id: "call".into(),
run_id: "run".into(),
conversation_id: "conversation".into(),
provider_call_index: 0,
request: ModelRequest {
prompt: PromptSpec {
instructions: "be brief".into(),
tools: Vec::new(),
},
model: ModelSpec::new("plugin:p/c/m"),
history: vec![
ProjectedMessage {
message_id: "m1".into(),
role: Role::User,
content: ProjectedContent::Parts(vec![ContentPart::Text {
text: "hi".into(),
}]),
},
ProjectedMessage {
message_id: "m2".into(),
role: Role::Assistant,
content: ProjectedContent::Assistant {
text: "".into(),
thinking: "t".into(),
replay_state: Some(ProviderReplayState {
provider_kind: "openai_responses".into(),
value: serde_json::json!({"items": []}),
}),
calls: vec![ToolCallContent {
index: 0,
call_id: "c1".into(),
name: "read".into(),
arguments: serde_json::json!({"path":"a"}),
}],
},
},
ProjectedMessage {
message_id: "m3".into(),
role: Role::Tool,
content: ProjectedContent::ToolResult(ToolResultContent {
call_id: "c1".into(),
name: "read".into(),
content: "data".into(),
is_error: false,
image: None,
provider_parts: Vec::new(),
}),
},
],
},
};
let request = llm_request(&invocation).unwrap();
assert_eq!(request["instructions"], "be brief");
assert_eq!(request["latency"], "standard");
assert_eq!(request["cacheKey"], "conversation");
let messages = request["messages"].as_array().unwrap();
assert_eq!(messages[0]["role"], "user");
assert_eq!(
messages[1]["replayState"]["providerKind"],
"openai_responses"
);
assert_eq!(messages[1]["toolCalls"][0]["callId"], "c1");
assert_eq!(messages[2]["role"], "tool");
assert_eq!(messages[2]["isError"], false);
}
#[test]
fn parses_plugin_events_into_model_events() {
assert_eq!(
model_event(&serde_json::json!({"type":"text-delta","text":"hi"})).unwrap(),
ModelEvent::TextDelta("hi".into())
);
assert_eq!(
model_event(&serde_json::json!({"type":"done","reason":"tool-use"})).unwrap(),
ModelEvent::Done(FinishReason::ToolUse)
);
let usage = model_event(&serde_json::json!({
"type":"usage",
"usage":{"inputTokens":10,"outputTokens":2,"cacheReadTokens":4}
}))
.unwrap();
assert_eq!(
usage,
ModelEvent::Usage(Usage {
input_tokens: Some(10),
context_input_tokens: Some(10),
output_tokens: Some(2),
total_tokens: None,
cache_read_tokens: Some(4),
cache_write_tokens: None,
reasoning_tokens: None,
})
);
assert!(model_event(&serde_json::json!({"type":"mystery"})).is_err());
}
}