fix: accept common tool parameter aliases

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
吉杨杰
2026-09-08 15:39:20 +08:00
co-authored by Cursor
parent 9637efd795
commit fc1e96d6a1
5 changed files with 113 additions and 25 deletions
+47 -1
View File
@@ -12,6 +12,18 @@ pub fn tool_query(id: u32, call: &ToolCall) -> Result<pb::AgentServerMessage> {
.map(str::to_string) .map(str::to_string)
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name))) .ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
}; };
// Claude 系模型常按 Claude Code 习惯输出别名参数(如 query),逐个回退兼容。
let string_aliased = |names: &[&str]| -> Result<String> {
for name in names {
if let Some(value) = call.arguments.get(name).and_then(Value::as_str) {
return Ok(value.to_string());
}
}
Err(Error::Protocol(format!(
"{} is missing {}",
call.name, names[0]
)))
};
let optional_string = |name: &str| { let optional_string = |name: &str| {
call.arguments call.arguments
.get(name) .get(name)
@@ -80,7 +92,7 @@ pub fn tool_query(id: u32, call: &ToolCall) -> Result<pb::AgentServerMessage> {
} }
"websearch" => Query::WebSearchRequestQuery(pb::WebSearchRequestQuery { "websearch" => Query::WebSearchRequestQuery(pb::WebSearchRequestQuery {
args: Some(pb::WebSearchArgs { args: Some(pb::WebSearchArgs {
search_term: string("search_term")?, search_term: string_aliased(&["search_term", "query"])?,
tool_call_id: call.call_id.clone(), tool_call_id: call.call_id.clone(),
}), }),
}), }),
@@ -214,3 +226,37 @@ fn normalized(value: &str) -> String {
.flat_map(char::to_lowercase) .flat_map(char::to_lowercase)
.collect() .collect()
} }
#[cfg(test)]
mod tests {
use serde_json::json;
use super::tool_query;
use crate::cursor::protocol::proto::agent::v1 as pb;
use crate::model::ToolCall;
#[test]
fn web_search_accepts_query_as_search_term_alias() {
// Claude 系模型常按 Claude Code 习惯发送 query 而非 search_term,
// 交互查询编码必须接受别名,而不是报 `WebSearch is missing search_term`。
let call = ToolCall {
index: 0,
call_id: "call-1".into(),
model_call_id: "model-1".into(),
name: "WebSearch".into(),
arguments_text: String::new(),
arguments: json!({ "query": "lmarena leaderboard" }),
argument_error: None,
};
let message = tool_query(1, &call).unwrap();
let Some(pb::agent_server_message::Message::InteractionQuery(query)) = message.message
else {
panic!("expected an InteractionQuery");
};
let Some(pb::interaction_query::Query::WebSearchRequestQuery(request)) = query.query
else {
panic!("expected a WebSearchRequestQuery");
};
assert_eq!(request.args.unwrap().search_term, "lmarena leaderboard");
}
}
+19 -5
View File
@@ -286,6 +286,20 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
.and_then(Value::as_str) .and_then(Value::as_str)
.map(str::to_string) .map(str::to_string)
}; };
// 与执行侧一致的参数别名兼容(如 Claude Code 习惯的 file_path),仅影响展示。
let aliased = |names: &[&str]| -> String {
names
.iter()
.find_map(|name| call.arguments.get(name).and_then(Value::as_str))
.unwrap_or_default()
.to_string()
};
let optional_aliased = |names: &[&str]| -> Option<String> {
names
.iter()
.find_map(|name| call.arguments.get(name).and_then(Value::as_str))
.map(str::to_string)
};
match output.tool.as_mut() { match output.tool.as_mut() {
Some(pb::tool_call::Tool::ShellToolCall(tool)) => { Some(pb::tool_call::Tool::ShellToolCall(tool)) => {
tool.description = optional("description"); tool.description = optional("description");
@@ -299,7 +313,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
} }
Some(pb::tool_call::Tool::DeleteToolCall(tool)) => { Some(pb::tool_call::Tool::DeleteToolCall(tool)) => {
tool.args = Some(pb::DeleteArgs { tool.args = Some(pb::DeleteArgs {
path: string("path"), path: aliased(&["path", "file_path", "filePath"]),
tool_call_id: call.call_id.clone(), tool_call_id: call.call_id.clone(),
}) })
} }
@@ -321,7 +335,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
} }
Some(pb::tool_call::Tool::ReadToolCall(tool)) => { Some(pb::tool_call::Tool::ReadToolCall(tool)) => {
tool.args = Some(pb::ReadToolArgs { tool.args = Some(pb::ReadToolArgs {
path: string("path"), path: aliased(&["path", "file_path", "filePath"]),
offset: call offset: call
.arguments .arguments
.get("offset") .get("offset")
@@ -350,7 +364,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
} }
Some(pb::tool_call::Tool::EditToolCall(tool)) => { Some(pb::tool_call::Tool::EditToolCall(tool)) => {
let stream_content = if normalized(&call.name) == "write" { let stream_content = if normalized(&call.name) == "write" {
optional("contents").unwrap_or_default() optional_aliased(&["contents", "content"]).unwrap_or_default()
} else { } else {
optional("new_string").unwrap_or_default() optional("new_string").unwrap_or_default()
}; };
@@ -358,7 +372,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
path: if normalized(&call.name) == "editnotebook" { path: if normalized(&call.name) == "editnotebook" {
string("target_notebook") string("target_notebook")
} else { } else {
string("path") aliased(&["path", "file_path", "filePath"])
}, },
stream_content: Some(edit::normalize_newlines(&stream_content)), stream_content: Some(edit::normalize_newlines(&stream_content)),
}) })
@@ -418,7 +432,7 @@ pub fn render_tool_call(call: &ToolCall, completed: bool) -> Result<pb::ToolCall
} }
Some(pb::tool_call::Tool::WebSearchToolCall(tool)) => { Some(pb::tool_call::Tool::WebSearchToolCall(tool)) => {
tool.args = Some(pb::WebSearchArgs { tool.args = Some(pb::WebSearchArgs {
search_term: string("search_term"), search_term: aliased(&["search_term", "query"]),
tool_call_id: call.call_id.clone(), tool_call_id: call.call_id.clone(),
}) })
} }
+14 -2
View File
@@ -22,6 +22,18 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
.map(str::to_string) .map(str::to_string)
.ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name))) .ok_or_else(|| Error::Protocol(format!("{} is missing {name}", call.name)))
}; };
// Claude 系模型常按 Claude Code 习惯输出别名参数(如 file_path),逐个回退兼容。
let string_aliased = |names: &[&str]| -> Result<String> {
for name in names {
if let Some(value) = call.arguments.get(name).and_then(Value::as_str) {
return Ok(value.to_string());
}
}
Err(Error::Protocol(format!(
"{} is missing {}",
call.name, names[0]
)))
};
let optional_string = |name: &str| { let optional_string = |name: &str| {
call.arguments call.arguments
.get(name) .get(name)
@@ -63,7 +75,7 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
}) })
} }
"read" => Message::ReadArgs(pb::ReadArgs { "read" => Message::ReadArgs(pb::ReadArgs {
path: string("path")?, path: string_aliased(&["path", "file_path", "filePath"])?,
tool_call_id: call.call_id.clone(), tool_call_id: call.call_id.clone(),
offset: int("offset"), offset: int("offset"),
limit: call limit: call
@@ -74,7 +86,7 @@ pub fn request(id: u32, call: &ToolCall, context: &ExecContext) -> Result<pb::Ag
encoding_hint: optional_string("encoding_hint"), encoding_hint: optional_string("encoding_hint"),
}), }),
"delete" => Message::DeleteArgs(pb::DeleteArgs { "delete" => Message::DeleteArgs(pb::DeleteArgs {
path: string("path")?, path: string_aliased(&["path", "file_path", "filePath"])?,
tool_call_id: call.call_id.clone(), tool_call_id: call.call_id.clone(),
}), }),
"grep" => Message::GrepArgs(pb::GrepArgs { "grep" => Message::GrepArgs(pb::GrepArgs {
+22 -11
View File
@@ -13,12 +13,12 @@ pub(crate) struct EditWrite {
} }
pub(crate) fn path(call: &ToolCall) -> Result<String> { pub(crate) fn path(call: &ToolCall) -> Result<String> {
let field = if normalized(&call.name) == "editnotebook" { if normalized(&call.name) == "editnotebook" {
"target_notebook" string(call, "target_notebook")
} else { } else {
"path" // Claude 系模型常按 Claude Code 习惯输出 file_path/filePath,做别名兼容。
}; string_any(call, &["path", "file_path", "filePath"])
string(call, field) }
} }
pub(crate) fn execution_path(call: &ToolCall) -> Result<Option<String>> { pub(crate) fn execution_path(call: &ToolCall) -> Result<Option<String>> {
@@ -63,7 +63,10 @@ pub(crate) fn after_read(
}; };
let after = match normalized(&call.name).as_str() { let after = match normalized(&call.name).as_str() {
"write" => { "write" => {
normalize_newlines(&string(call, "contents").map_err(|error| error.to_string())?) // Claude Code 习惯的 content 作为 contents 的别名兼容。
normalize_newlines(
&string_any(call, &["contents", "content"]).map_err(|error| error.to_string())?,
)
} }
"strreplace" => replace_string(call, &before)?, "strreplace" => replace_string(call, &before)?,
"editnotebook" => edit_notebook(call, &before)?, "editnotebook" => edit_notebook(call, &before)?,
@@ -230,11 +233,19 @@ fn source_lines(value: &str) -> Vec<Value> {
} }
fn string(call: &ToolCall, field: &str) -> Result<String> { fn string(call: &ToolCall, field: &str) -> Result<String> {
call.arguments string_any(call, &[field])
.get(field) }
.and_then(Value::as_str)
.map(str::to_owned) fn string_any(call: &ToolCall, fields: &[&str]) -> Result<String> {
.ok_or_else(|| Error::Protocol(format!("{} is missing {field}", call.name))) for field in fields {
if let Some(value) = call.arguments.get(field).and_then(Value::as_str) {
return Ok(value.to_owned());
}
}
Err(Error::Protocol(format!(
"{} is missing {}",
call.name, fields[0]
)))
} }
fn normalized(value: &str) -> String { fn normalized(value: &str) -> String {
@@ -88,12 +88,17 @@ fn start_web_search(
search: WebSearch, search: WebSearch,
pending: PendingInteraction, pending: PendingInteraction,
) -> Result<()> { ) -> Result<()> {
let query = pending // Claude Code 习惯的 query 作为 search_term 的别名兼容。
.call let query = ["search_term", "query"]
.arguments .iter()
.get("search_term") .find_map(|name| {
.and_then(serde_json::Value::as_str) pending
.filter(|query| !query.trim().is_empty()) .call
.arguments
.get(name)
.and_then(serde_json::Value::as_str)
.filter(|value| !value.trim().is_empty())
})
.ok_or_else(|| Error::Protocol("WebSearch is missing search_term".into()))? .ok_or_else(|| Error::Protocol("WebSearch is missing search_term".into()))?
.to_string(); .to_string();
tokio::spawn(async move { tokio::spawn(async move {