mirror of
https://wget.la/https://github.com/leookun/cursor-byok
synced 2026-10-07 06:04:53 +08:00
修复:兼容已移除工具,避免旧会话 Resume 中断
兼容 v0.1.5-beta.1 删除 AwaitShell 后的旧会话 Resume,并将未知/已移除工具降级为模型可见失败结果,避免整个 Agent Run 被 Protocol Error 直接终止。
This commit is contained in:
@@ -4,19 +4,61 @@ mod render;
|
|||||||
use std::{collections::BTreeMap, time::Duration};
|
use std::{collections::BTreeMap, time::Duration};
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
cursor::proto::agent::v1 as pb,
|
cursor::{proto::agent::v1 as pb, tools::compat},
|
||||||
model::{ToolCall, Usage},
|
model::{ToolCall, Usage},
|
||||||
provider::ModelEvent,
|
provider::ModelEvent,
|
||||||
Result,
|
Error, Result,
|
||||||
};
|
};
|
||||||
|
|
||||||
pub use query::tool_query;
|
pub use query::tool_query;
|
||||||
pub(crate) use render::{create_plan_partial, edit_content_delta, edit_path_partial};
|
pub(crate) use render::{create_plan_partial, edit_content_delta, edit_path_partial};
|
||||||
pub use render::{
|
pub use render::{dynamic_mcp_placeholder, render_dynamic_mcp, tool_completed};
|
||||||
dynamic_mcp_placeholder, render_dynamic_mcp, render_tool_call, tool_completed,
|
use render::{
|
||||||
tool_placeholder, tool_started,
|
render_tool_call as render_builtin_tool_call, tool_placeholder as builtin_tool_placeholder,
|
||||||
|
tool_started as builtin_tool_started,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
pub fn tool_placeholder(name: &str, call_id: &str) -> Result<pb::ToolCall> {
|
||||||
|
match builtin_tool_placeholder(name, call_id) {
|
||||||
|
Ok(tool) => Ok(tool),
|
||||||
|
Err(error) if is_unsupported_tool(&error, name) => Ok(compat::placeholder(name, call_id)),
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall> {
|
||||||
|
match render_builtin_tool_call(call, completed) {
|
||||||
|
Ok(tool) => Ok(tool),
|
||||||
|
Err(error) if is_unsupported_tool(&error, &call.name) => {
|
||||||
|
Ok(compat::render(call, completed))
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn tool_started(
|
||||||
|
call: &ToolCall,
|
||||||
|
dynamic_mcp: Option<&pb::McpToolDefinition>,
|
||||||
|
) -> Result<pb::AgentServerMessage> {
|
||||||
|
match builtin_tool_started(call, dynamic_mcp) {
|
||||||
|
Ok(message) => Ok(message),
|
||||||
|
Err(error) if dynamic_mcp.is_none() && is_unsupported_tool(&error, &call.name) => {
|
||||||
|
Ok(server_interaction(
|
||||||
|
pb::interaction_update::Message::ToolCallStarted(pb::ToolCallStartedUpdate {
|
||||||
|
call_id: call.call_id.clone(),
|
||||||
|
tool_call: Some(compat::render(call, false)),
|
||||||
|
model_call_id: call.model_call_id.clone(),
|
||||||
|
}),
|
||||||
|
))
|
||||||
|
}
|
||||||
|
Err(error) => Err(error),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn is_unsupported_tool(error: &Error, name: &str) -> bool {
|
||||||
|
matches!(error, Error::Protocol(message) if message == &format!("unsupported tool: {name}"))
|
||||||
|
}
|
||||||
|
|
||||||
pub fn response_event(
|
pub fn response_event(
|
||||||
event: &ModelEvent,
|
event: &ModelEvent,
|
||||||
model_call_id: &str,
|
model_call_id: &str,
|
||||||
@@ -204,3 +246,43 @@ pub fn server_interaction(message: pb::interaction_update::Message) -> pb::Agent
|
|||||||
)),
|
)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn unknown_tool(name: &str) -> ToolCall {
|
||||||
|
let arguments = serde_json::json!({"shell_id": "legacy-shell", "value": 1});
|
||||||
|
ToolCall {
|
||||||
|
index: 0,
|
||||||
|
call_id: "call-1".into(),
|
||||||
|
model_call_id: "model-call-1".into(),
|
||||||
|
name: name.into(),
|
||||||
|
arguments_text: arguments.to_string(),
|
||||||
|
arguments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retired_tool_streaming_uses_a_compatibility_card() {
|
||||||
|
let call = unknown_tool("AwaitShell");
|
||||||
|
|
||||||
|
assert!(tool_placeholder(&call.name, &call.call_id).is_ok());
|
||||||
|
assert!(render_tool_call(&call, false).is_ok());
|
||||||
|
assert!(tool_started(&call, None).is_ok());
|
||||||
|
assert!(arguments_delta(&call, "{\"shell_id\":").is_ok());
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arbitrary_unknown_tool_start_does_not_fail_the_agent_stream() {
|
||||||
|
let event = ModelEvent::ToolCallStart {
|
||||||
|
index: 0,
|
||||||
|
call_id: "call-1".into(),
|
||||||
|
name: "OldTool".into(),
|
||||||
|
};
|
||||||
|
|
||||||
|
assert!(response_event(&event, "model-call-1", &BTreeMap::new())
|
||||||
|
.unwrap()
|
||||||
|
.is_some());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -0,0 +1,155 @@
|
|||||||
|
use crate::{
|
||||||
|
cursor::proto::agent::v1 as pb,
|
||||||
|
model::{ToolCall, ToolResult},
|
||||||
|
};
|
||||||
|
|
||||||
|
use super::{codec, result::ToolCompletion, runtime::now_ms};
|
||||||
|
|
||||||
|
// Unknown/retired tools use a generic Cursor MCP card only as a wire/UI
|
||||||
|
// representation; they are never dispatched to an MCP server.
|
||||||
|
const COMPAT_PROVIDER: &str = "cursor-byok-compat";
|
||||||
|
|
||||||
|
pub(crate) fn placeholder(name: &str, call_id: &str) -> pb::ToolCall {
|
||||||
|
pb::ToolCall {
|
||||||
|
hook_additional_contexts: Vec::new(),
|
||||||
|
tool_call_id: Some(call_id.into()),
|
||||||
|
started_at_ms: None,
|
||||||
|
completed_at_ms: None,
|
||||||
|
tool: Some(pb::tool_call::Tool::McpToolCall(pb::McpToolCall {
|
||||||
|
args: Some(pb::McpArgs {
|
||||||
|
name: name.into(),
|
||||||
|
tool_call_id: call_id.into(),
|
||||||
|
provider_identifier: COMPAT_PROVIDER.into(),
|
||||||
|
tool_name: name.into(),
|
||||||
|
server_identifier: COMPAT_PROVIDER.into(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
result: None,
|
||||||
|
description: Some("Unavailable legacy or unsupported tool".into()),
|
||||||
|
})),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn render(call: &ToolCall, completed: bool) -> pb::ToolCall {
|
||||||
|
let mut output = placeholder(&call.name, &call.call_id);
|
||||||
|
let timestamp = now_ms();
|
||||||
|
output.started_at_ms = Some(timestamp);
|
||||||
|
output.completed_at_ms = completed.then_some(timestamp);
|
||||||
|
if let Some(pb::tool_call::Tool::McpToolCall(tool)) = output.tool.as_mut() {
|
||||||
|
if let Some(args) = tool.args.as_mut() {
|
||||||
|
args.args = call
|
||||||
|
.arguments
|
||||||
|
.as_object()
|
||||||
|
.map(codec::json_object_to_prost)
|
||||||
|
.unwrap_or_default();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
output
|
||||||
|
}
|
||||||
|
|
||||||
|
pub(crate) fn failure(call: &ToolCall) -> ToolCompletion {
|
||||||
|
let error = failure_message(&call.name);
|
||||||
|
let arguments = call
|
||||||
|
.arguments
|
||||||
|
.as_object()
|
||||||
|
.map(codec::json_object_to_prost)
|
||||||
|
.unwrap_or_default();
|
||||||
|
ToolCompletion::new(
|
||||||
|
call,
|
||||||
|
now_ms(),
|
||||||
|
ToolResult {
|
||||||
|
call_id: call.call_id.clone(),
|
||||||
|
content: error.clone(),
|
||||||
|
is_error: true,
|
||||||
|
image: None,
|
||||||
|
},
|
||||||
|
pb::tool_call::Tool::McpToolCall(pb::McpToolCall {
|
||||||
|
args: Some(pb::McpArgs {
|
||||||
|
name: call.name.clone(),
|
||||||
|
args: arguments,
|
||||||
|
tool_call_id: call.call_id.clone(),
|
||||||
|
provider_identifier: COMPAT_PROVIDER.into(),
|
||||||
|
tool_name: call.name.clone(),
|
||||||
|
server_identifier: COMPAT_PROVIDER.into(),
|
||||||
|
..Default::default()
|
||||||
|
}),
|
||||||
|
result: Some(pb::McpToolResult {
|
||||||
|
result: Some(pb::mcp_tool_result::Result::Error(pb::McpToolError {
|
||||||
|
error,
|
||||||
|
read_tool_def_reminder: String::new(),
|
||||||
|
})),
|
||||||
|
}),
|
||||||
|
description: Some("Unavailable legacy or unsupported tool".into()),
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn failure_message(name: &str) -> String {
|
||||||
|
if normalized(name) == "awaitshell" {
|
||||||
|
return "Tool \"AwaitShell\" is no longer available in this Cursor BYOK version. This call may have been restored from a conversation created by an older version. Treat the tool call as failed and continue using only tools advertised in the current prompt; for background shell work, use the current Shell/background completion flow.".into();
|
||||||
|
}
|
||||||
|
format!(
|
||||||
|
"Tool \"{name}\" is not available in this Cursor BYOK version. It may come from an older conversation or from an unsupported model-generated tool call. Treat the tool call as failed and continue using a tool advertised in the current prompt."
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn normalized(name: &str) -> String {
|
||||||
|
name.chars()
|
||||||
|
.filter(|character| character.is_ascii_alphanumeric())
|
||||||
|
.flat_map(char::to_lowercase)
|
||||||
|
.collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
fn tool(name: &str) -> ToolCall {
|
||||||
|
let arguments = serde_json::json!({"shell_id": "legacy-shell", "block_until_ms": 30000});
|
||||||
|
ToolCall {
|
||||||
|
index: 0,
|
||||||
|
call_id: "call-1".into(),
|
||||||
|
model_call_id: "model-call-1".into(),
|
||||||
|
name: name.into(),
|
||||||
|
arguments_text: arguments.to_string(),
|
||||||
|
arguments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retired_await_shell_is_a_model_visible_failure() {
|
||||||
|
let completion = failure(&tool("AwaitShell"));
|
||||||
|
assert!(completion.result().is_error);
|
||||||
|
assert!(completion.result().content.contains("older version"));
|
||||||
|
assert!(completion
|
||||||
|
.result()
|
||||||
|
.content
|
||||||
|
.contains("current Shell/background completion flow"));
|
||||||
|
let Some(pb::tool_call::Tool::McpToolCall(rendered)) = completion.tool_call().tool.as_ref()
|
||||||
|
else {
|
||||||
|
panic!("expected compatibility MCP card");
|
||||||
|
};
|
||||||
|
let args = rendered.args.as_ref().unwrap();
|
||||||
|
assert_eq!(args.provider_identifier, COMPAT_PROVIDER);
|
||||||
|
assert_eq!(args.tool_name, "AwaitShell");
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arbitrary_unknown_tool_is_a_model_visible_failure() {
|
||||||
|
let completion = failure(&tool("OldTool"));
|
||||||
|
assert!(completion.result().is_error);
|
||||||
|
assert!(completion.result().content.contains("not available"));
|
||||||
|
assert_eq!(
|
||||||
|
completion
|
||||||
|
.tool_call()
|
||||||
|
.tool
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|tool| match tool {
|
||||||
|
pb::tool_call::Tool::McpToolCall(tool) => tool.args.as_ref(),
|
||||||
|
_ => None,
|
||||||
|
})
|
||||||
|
.map(|args| args.tool_name.as_str()),
|
||||||
|
Some("OldTool")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -15,6 +15,7 @@ use crate::{
|
|||||||
};
|
};
|
||||||
|
|
||||||
use super::{
|
use super::{
|
||||||
|
compat,
|
||||||
result::{ToolCompletion, ToolResultSender},
|
result::{ToolCompletion, ToolResultSender},
|
||||||
runtime::{CursorToolRuntime, ExecContext, PendingInteraction},
|
runtime::{CursorToolRuntime, ExecContext, PendingInteraction},
|
||||||
};
|
};
|
||||||
@@ -61,7 +62,14 @@ pub(super) async fn start(
|
|||||||
| "generateimage" => interaction::start(runtime, call).await,
|
| "generateimage" => interaction::start(runtime, call).await,
|
||||||
"todowrite" | "updatecurrentstep" => local::start(call, message_index),
|
"todowrite" | "updatecurrentstep" => local::start(call, message_index),
|
||||||
"semblesearch" | "semblefindrelated" => semble::start(results, call, store.cloned()),
|
"semblesearch" | "semblefindrelated" => semble::start(results, call, store.cloned()),
|
||||||
_ => Err(Error::Protocol(format!("unsupported tool: {}", call.name))),
|
_ => Ok(unavailable_tool(call)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unavailable_tool(call: &ToolCall) -> ToolStart {
|
||||||
|
ToolStart {
|
||||||
|
messages: Vec::new(),
|
||||||
|
completion: Some(compat::failure(call)),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -196,4 +204,28 @@ mod tests {
|
|||||||
"protocol error: Shell block_until_ms is out of range"
|
"protocol error: Shell block_until_ms is out of range"
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn retired_await_shell_does_not_become_a_protocol_error() {
|
||||||
|
let call = tool(
|
||||||
|
"AwaitShell",
|
||||||
|
serde_json::json!({"shell_id": "legacy-shell", "block_until_ms": 30_000}),
|
||||||
|
);
|
||||||
|
let started = unavailable_tool(&call);
|
||||||
|
let completion = started.completion.expect("compatibility completion");
|
||||||
|
|
||||||
|
assert!(started.messages.is_empty());
|
||||||
|
assert!(completion.result().is_error);
|
||||||
|
assert!(completion.result().content.contains("older version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn arbitrary_unknown_tool_does_not_become_a_protocol_error() {
|
||||||
|
let call = tool("OldTool", serde_json::json!({"value": 1}));
|
||||||
|
let started = unavailable_tool(&call);
|
||||||
|
let completion = started.completion.expect("compatibility completion");
|
||||||
|
|
||||||
|
assert!(completion.result().is_error);
|
||||||
|
assert!(completion.result().content.contains("not available"));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ use std::{
|
|||||||
use tokio::sync::Mutex;
|
use tokio::sync::Mutex;
|
||||||
|
|
||||||
pub mod codec;
|
pub mod codec;
|
||||||
|
pub(crate) mod compat;
|
||||||
mod dispatch;
|
mod dispatch;
|
||||||
pub(crate) mod edit;
|
pub(crate) mod edit;
|
||||||
pub(crate) mod result;
|
pub(crate) mod result;
|
||||||
|
|||||||
@@ -0,0 +1,76 @@
|
|||||||
|
use std::collections::{BTreeMap, HashSet};
|
||||||
|
|
||||||
|
use cursor_server::{
|
||||||
|
cursor::tools::{
|
||||||
|
runtime::{CursorToolRuntime, ExecContext},
|
||||||
|
ToolBatchState, ToolDispatcher,
|
||||||
|
},
|
||||||
|
model::ToolCall,
|
||||||
|
};
|
||||||
|
|
||||||
|
fn tool(name: &str) -> ToolCall {
|
||||||
|
let arguments = serde_json::json!({
|
||||||
|
"shell_id": "legacy-shell",
|
||||||
|
"block_until_ms": 30_000
|
||||||
|
});
|
||||||
|
ToolCall {
|
||||||
|
index: 0,
|
||||||
|
call_id: "call-1".into(),
|
||||||
|
model_call_id: "model-call-1".into(),
|
||||||
|
name: name.into(),
|
||||||
|
arguments_text: arguments.to_string(),
|
||||||
|
arguments,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn dispatch(
|
||||||
|
name: &str,
|
||||||
|
) -> cursor_server::Result<cursor_server::cursor::tools::DispatchedTool> {
|
||||||
|
let dispatcher = ToolDispatcher::new(CursorToolRuntime::default());
|
||||||
|
let completed = HashSet::new();
|
||||||
|
let started = HashSet::new();
|
||||||
|
let call = tool(name);
|
||||||
|
let dispatched = dispatcher
|
||||||
|
.start_batch(
|
||||||
|
&[call],
|
||||||
|
ToolBatchState {
|
||||||
|
completed: &completed,
|
||||||
|
started: &started,
|
||||||
|
response_text: "",
|
||||||
|
response_thinking: "",
|
||||||
|
},
|
||||||
|
&[],
|
||||||
|
&BTreeMap::new(),
|
||||||
|
&ExecContext::default(),
|
||||||
|
)
|
||||||
|
.await?;
|
||||||
|
Ok(dispatched.into_iter().next().expect("one dispatched tool"))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn resumed_await_shell_becomes_a_failed_tool_result_instead_of_a_protocol_error() {
|
||||||
|
let dispatched = dispatch("AwaitShell").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
dispatched.messages.len(),
|
||||||
|
1,
|
||||||
|
"started card is still published"
|
||||||
|
);
|
||||||
|
let completion = dispatched.completion.expect("compatibility completion");
|
||||||
|
assert!(completion.result().is_error);
|
||||||
|
assert!(completion.result().content.contains("older version"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::test]
|
||||||
|
async fn hallucinated_unknown_tool_becomes_a_failed_tool_result_instead_of_a_protocol_error() {
|
||||||
|
let dispatched = dispatch("OldTool").await.unwrap();
|
||||||
|
|
||||||
|
assert_eq!(
|
||||||
|
dispatched.messages.len(),
|
||||||
|
1,
|
||||||
|
"started card is still published"
|
||||||
|
);
|
||||||
|
let completion = dispatched.completion.expect("compatibility completion");
|
||||||
|
assert!(completion.result().is_error);
|
||||||
|
assert!(completion.result().content.contains("not available"));
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user