fix: retry tool agents across provider compatibility modes

This commit is contained in:
冰朔 2026-07-22 15:24:54 +08:00
parent 612932f714
commit 85c56230d0
2 changed files with 58 additions and 41 deletions

View File

@ -19,10 +19,6 @@ const MAGIC_BRUSH_CONTINUATION_WORDS: &[&str] = &[
"continue", "go ahead", "do it", "try it", "继续", "接着", "开始", "执行", "操作", "试试",
"你来", "好的", "可以", "",
];
const MAGIC_BRUSH_MUTATION_WORDS: &[&str] = &[
"create", "write", "edit", "update", "delete", "save", "创建", "新建", "写入", "编辑", "修改",
"更新", "删除", "保存",
];
const MAGIC_BRUSH_TOOL_JSON: &str = r#"{
"type": "function",
"function": {
@ -89,14 +85,7 @@ pub(crate) fn openai_chat_payload(request: &AiModelStreamRequest) -> serde_json:
});
if offers_tools {
payload["tools"] = serde_json::Value::Array(openai_vault_tools());
payload["tool_choice"] = serde_json::Value::String(
if message_continues_pending_tool_work(&request.message) {
"required"
} else {
"auto"
}
.into(),
);
payload["tool_choice"] = serde_json::Value::String("auto".into());
}
payload
}
@ -235,33 +224,6 @@ fn message_needs_magic_brush(message: &str) -> bool {
.any(|word| message.to_lowercase().contains(word))
}
fn message_continues_pending_tool_work(message: &str) -> bool {
let latest = message
.rsplit("[user]:")
.next()
.unwrap_or(message)
.to_lowercase();
if !MAGIC_BRUSH_CONTINUATION_WORDS
.iter()
.any(|word| latest.contains(word))
{
return false;
}
let recent = message
.chars()
.rev()
.take(4_000)
.collect::<String>()
.chars()
.rev()
.collect::<String>()
.to_lowercase();
MAGIC_BRUSH_MUTATION_WORDS
.iter()
.any(|word| recent.contains(word))
}
fn selected_model_supports_tools(request: &AiModelStreamRequest) -> bool {
request
.provider
@ -944,7 +906,7 @@ mod tests {
assert!(payload["tools"]
.as_array()
.is_some_and(|tools| !tools.is_empty()));
assert_eq!(payload["tool_choice"], "required");
assert_eq!(payload["tool_choice"], "auto");
assert_eq!(payload["stream"], false);
}

View File

@ -220,10 +220,38 @@ where
}
run_openai_agent_loop(request, payload, emit, |payload| {
send_json_request(request, endpoint.clone(), payload)
send_openai_payload_with_tool_choice_fallback(payload, |attempt| {
send_json_request(request, endpoint.clone(), attempt)
})
})
}
fn send_openai_payload_with_tool_choice_fallback<S>(
mut payload: serde_json::Value,
mut send: S,
) -> Result<serde_json::Value, String>
where
S: FnMut(serde_json::Value) -> Result<serde_json::Value, String>,
{
match send(payload.clone()) {
Err(error) if tool_choice_compatibility_error(&error) => {
if let Some(object) = payload.as_object_mut() {
object.remove("tool_choice");
}
send(payload)
}
result => result,
}
}
fn tool_choice_compatibility_error(error: &str) -> bool {
let normalized = error.to_ascii_lowercase();
normalized.contains("tool_choice")
&& (normalized.contains("does not support")
|| normalized.contains("unsupported")
|| normalized.contains("not supported"))
}
fn run_openai_agent_loop<F, S>(
request: &AiModelStreamRequest,
mut payload: serde_json::Value,
@ -907,6 +935,33 @@ mod tests {
&& message.contains("correct the arguments")));
}
#[test]
fn tool_choice_compatibility_error_retries_without_the_field() {
let payload = json!({
"messages": [{ "role": "user", "content": "Continue" }],
"tools": [{ "type": "function" }],
"tool_choice": "auto",
"stream": false,
});
let mut attempts = Vec::new();
let response = send_openai_payload_with_tool_choice_fallback(payload, |attempt| {
attempts.push(attempt);
if attempts.len() == 1 {
Err("AI provider returned 400 Bad Request: Thinking mode does not support this tool_choice".into())
} else {
Ok(json!({ "choices": [{ "message": { "content": "continued" } }] }))
}
})
.unwrap();
assert_eq!(response["choices"][0]["message"]["content"], "continued");
assert_eq!(attempts.len(), 2);
assert_eq!(attempts[0]["tool_choice"], "auto");
assert!(attempts[1].get("tool_choice").is_none());
assert!(attempts[1]["tools"].is_array());
}
#[test]
fn agent_loop_reads_then_edits_before_returning_final_text() {
let dir = tempfile::tempdir().unwrap();