From 612932f71473d7780c05aebb5652e30a8d2b9887 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E5=86=B0=E6=9C=94?= <565183519@qq.com> Date: Wed, 22 Jul 2026 15:07:06 +0800 Subject: [PATCH] fix: run built-in models as bounded tool agents --- src-tauri/src/ai_model_tools.rs | 44 ++++++- src-tauri/src/ai_models.rs | 199 +++++++++++++++++++++++++++++--- 2 files changed, 225 insertions(+), 18 deletions(-) diff --git a/src-tauri/src/ai_model_tools.rs b/src-tauri/src/ai_model_tools.rs index 838e256..fdc5a82 100644 --- a/src-tauri/src/ai_model_tools.rs +++ b/src-tauri/src/ai_model_tools.rs @@ -129,6 +129,12 @@ where run_openai_tool_calls(request, &tool_calls, emit).map(Some) } +pub(crate) fn openai_response_has_tool_calls(json: &serde_json::Value) -> bool { + json["choices"][0]["message"]["tool_calls"] + .as_array() + .is_some_and(|calls| !calls.is_empty()) +} + fn openai_chat_messages( request: &AiModelStreamRequest, offers_tools: bool, @@ -561,8 +567,17 @@ where raw_arguments: arguments.to_string(), arguments, }; - let result = execute_openai_tool_call_with_events(request, &call, emit)?; - results.push(result); + match execute_openai_tool_call_with_events(request, &call, emit) { + Ok(result) => results.push(result), + Err(error) if results.is_empty() => return Err(error), + Err(error) => { + return Err(format!( + "Completed steps before failure:\n{}\n\nStep {} ({tool}) failed: {error}", + results.join("\n"), + index + 1, + )); + } + } } Ok(format!( "神笔马良已完成“{purpose}”:\n{}", @@ -1166,6 +1181,31 @@ mod tests { assert!(!dir.path().join(".hololake/tools").exists()); } + #[test] + fn magic_brush_reports_completed_steps_before_a_later_failure() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("memory.md"), "# Existing memory\n").unwrap(); + let request = request(dir.path().to_string_lossy().into_owned()); + let response = tool_call_response(json!({ + "function": { + "name": MAGIC_BRUSH_TOOL_NAME, + "arguments": serde_json::to_string(&json!({ + "purpose": "read then edit", + "steps": [ + { "tool": GET_NOTE_TOOL_NAME, "arguments": { "path": "memory.md" } }, + { "tool": EDIT_NOTE_TOOL_NAME, "arguments": { "path": "memory.md" } } + ] + })).unwrap() + } + })); + + let error = execute_openai_tool_calls(&request, &response, |_| {}).unwrap_err(); + + assert!(error.contains("Completed steps before failure")); + assert!(error.contains("# Existing memory")); + assert!(error.contains("edit_note requires content")); + } + #[test] fn execute_openai_tool_calls_repairs_json5_arguments() { let dir = tempfile::tempdir().unwrap(); diff --git a/src-tauri/src/ai_models.rs b/src-tauri/src/ai_models.rs index 6db6b3b..c42219f 100644 --- a/src-tauri/src/ai_models.rs +++ b/src-tauri/src/ai_models.rs @@ -218,23 +218,89 @@ where if crate::ai_model_tools::openai_payload_streams(&payload) { return send_openai_stream(request, endpoint, payload, emit); } - let json = send_json_request(request, endpoint, payload)?; - if let Some(tool_summary) = - crate::ai_model_tools::execute_openai_tool_calls(request, &json, &mut *emit)? - { - emit(AiAgentStreamEvent::ThinkingDelta { - text: "工具读取完成,正在继续整理回答……".into(), - }); - let mut continuation = request.clone(); - continuation.vault_path = None; - continuation.vault_paths.clear(); - continuation.message = format!( - "{}\n\nThe requested tool work completed successfully. Continue the same conversation and answer the user using this result:\n{}", - request.message, tool_summary, - ); - return send_openai_compatible_message(&continuation, emit); + + run_openai_agent_loop(request, payload, emit, |payload| { + send_json_request(request, endpoint.clone(), payload) + }) +} + +fn run_openai_agent_loop( + request: &AiModelStreamRequest, + mut payload: serde_json::Value, + emit: &mut F, + mut send: S, +) -> Result +where + F: FnMut(AiAgentStreamEvent), + S: FnMut(serde_json::Value) -> Result, +{ + const MAX_TOOL_ROUNDS: usize = 8; + + let mut tool_round = 0; + loop { + let json = send(payload.clone())?; + if !crate::ai_model_tools::openai_response_has_tool_calls(&json) { + return extract_openai_text(&json); + } + + tool_round += 1; + let execution = + crate::ai_model_tools::execute_openai_tool_calls(request, &json, &mut *emit); + if tool_round >= MAX_TOOL_ROUNDS { + return Err(format!( + "Agent stopped after {MAX_TOOL_ROUNDS} tool rounds to prevent an infinite loop. The last tool result was: {}", + match execution { + Ok(Some(summary)) => summary, + Ok(None) => "No tool result was returned.".into(), + Err(error) => error, + } + )); + } + + match execution { + Ok(Some(summary)) => { + emit(AiAgentStreamEvent::ThinkingDelta { + text: format!("第 {tool_round} 轮工具执行完成,正在根据结果继续处理……"), + }); + append_openai_agent_round(&mut payload, tool_round, Ok(&summary))?; + } + Ok(None) => { + append_openai_agent_round( + &mut payload, + tool_round, + Err("The provider returned a tool call without a tool result."), + )?; + } + Err(error) => { + emit(AiAgentStreamEvent::ThinkingDelta { + text: format!("第 {tool_round} 轮工具参数有误,正在修正后继续……"), + }); + append_openai_agent_round(&mut payload, tool_round, Err(&error))?; + } + } } - extract_openai_text(&json) +} + +fn append_openai_agent_round( + payload: &mut serde_json::Value, + round: usize, + result: Result<&str, &str>, +) -> Result<(), String> { + let messages = payload["messages"] + .as_array_mut() + .ok_or_else(|| "AI agent payload did not include a messages array.".to_string())?; + let content = match result { + Ok(summary) => format!( + "Tool round {round} completed with this exact result:\n{summary}\n\nIf the task is unfinished, call the next tool now. Only give the final answer after every requested operation has an explicit successful tool result. Do not claim a write, edit, or deletion that is not present in the tool results." + ), + Err(error) => format!( + "Tool round {round} failed with this exact error:\n{error}\n\nUse the tool skill instructions to correct the arguments and call the tool again now. Do not claim the failed operation succeeded." + ), + }; + messages.push(serde_json::json!({ "role": "user", "content": content })); + payload["tool_choice"] = serde_json::Value::String("auto".into()); + payload["stream"] = serde_json::Value::Bool(false); + Ok(()) } fn send_openai_stream( @@ -805,6 +871,107 @@ mod tests { ); } + #[test] + fn agent_continuation_keeps_tools_after_a_read_round() { + let mut payload = json!({ + "messages": [{ "role": "user", "content": "Edit the note" }], + "tools": [{ "type": "function" }], + "tool_choice": "required", + "stream": false, + }); + + append_openai_agent_round(&mut payload, 1, Ok("# Current note\n")).unwrap(); + + assert_eq!(payload["tool_choice"], "auto"); + assert!(payload["tools"].is_array()); + assert_eq!(payload["messages"][1]["role"], "user"); + assert!(payload["messages"][1]["content"].as_str().is_some_and( + |message| message.contains("If the task is unfinished, call the next tool now") + )); + } + + #[test] + fn agent_continuation_returns_tool_errors_for_model_repair() { + let mut payload = json!({ + "messages": [{ "role": "user", "content": "Edit the note" }], + "tools": [{ "type": "function" }], + "tool_choice": "required", + "stream": false, + }); + + append_openai_agent_round(&mut payload, 2, Err("edit_note requires content.")).unwrap(); + + assert!(payload["messages"][1]["content"] + .as_str() + .is_some_and(|message| message.contains("edit_note requires content") + && message.contains("correct the arguments"))); + } + + #[test] + fn agent_loop_reads_then_edits_before_returning_final_text() { + let dir = tempfile::tempdir().unwrap(); + fs::write(dir.path().join("page.md"), "# Before\n").unwrap(); + let mut request = request(provider(AiModelProviderKind::OpenAi)); + request.vault_path = Some(dir.path().to_string_lossy().into_owned()); + let mut responses = vec![ + json!({ + "choices": [{ "message": { "tool_calls": [{ + "id": "read-round", + "function": { + "name": "magic_brush", + "arguments": serde_json::to_string(&json!({ + "purpose": "read before editing", + "steps": [{ "tool": "get_note", "arguments": { "path": "page.md" } }] + })).unwrap() + } + }] } }] + }), + json!({ + "choices": [{ "message": { "tool_calls": [{ + "id": "edit-round", + "function": { + "name": "magic_brush", + "arguments": serde_json::to_string(&json!({ + "purpose": "write the requested change", + "steps": [{ "tool": "edit_note", "arguments": { + "path": "page.md", + "content": "# After\n\n[[Linked page]]\n" + } }] + })).unwrap() + } + }] } }] + }), + json!({ "choices": [{ "message": { "content": "Verified complete." } }] }), + ]; + let mut sent_payloads = Vec::new(); + let mut events = Vec::new(); + + let answer = run_openai_agent_loop( + &request, + json!({ + "messages": [{ "role": "user", "content": "Edit page.md" }], + "tools": [{ "type": "function" }], + "tool_choice": "required", + "stream": false, + }), + &mut |event| events.push(event), + |payload| { + sent_payloads.push(payload); + Ok(responses.remove(0)) + }, + ) + .unwrap(); + + assert_eq!(answer, "Verified complete."); + assert_eq!(fs::read_to_string(dir.path().join("page.md")).unwrap(), "# After\n\n[[Linked page]]"); + assert_eq!(sent_payloads.len(), 3); + assert!(sent_payloads[1]["messages"].as_array().unwrap().last().unwrap()["content"] + .as_str().unwrap().contains("# Before")); + assert!(sent_payloads[2]["messages"].as_array().unwrap().last().unwrap()["content"] + .as_str().unwrap().contains("已更新笔记")); + assert!(events.iter().filter(|event| matches!(event, AiAgentStreamEvent::ToolDone { .. })).count() >= 4); + } + #[test] fn parses_openai_sse_text_deltas_without_treating_done_as_text() { assert_eq!(