fix: run built-in models as bounded tool agents
This commit is contained in:
parent
cd51b858dc
commit
612932f714
@ -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();
|
||||
|
||||
@ -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!(
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user