fix: run built-in models as bounded tool agents

This commit is contained in:
冰朔 2026-07-22 15:07:06 +08:00
parent cd51b858dc
commit 612932f714
2 changed files with 225 additions and 18 deletions

View File

@ -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();

View File

@ -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<F, S>(
request: &AiModelStreamRequest,
mut payload: serde_json::Value,
emit: &mut F,
mut send: S,
) -> Result<String, String>
where
F: FnMut(AiAgentStreamEvent),
S: FnMut(serde_json::Value) -> Result<serde_json::Value, String>,
{
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<F>(
@ -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!(