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)
|
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(
|
fn openai_chat_messages(
|
||||||
request: &AiModelStreamRequest,
|
request: &AiModelStreamRequest,
|
||||||
offers_tools: bool,
|
offers_tools: bool,
|
||||||
@ -561,8 +567,17 @@ where
|
|||||||
raw_arguments: arguments.to_string(),
|
raw_arguments: arguments.to_string(),
|
||||||
arguments,
|
arguments,
|
||||||
};
|
};
|
||||||
let result = execute_openai_tool_call_with_events(request, &call, emit)?;
|
match execute_openai_tool_call_with_events(request, &call, emit) {
|
||||||
results.push(result);
|
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!(
|
Ok(format!(
|
||||||
"神笔马良已完成“{purpose}”:\n{}",
|
"神笔马良已完成“{purpose}”:\n{}",
|
||||||
@ -1166,6 +1181,31 @@ mod tests {
|
|||||||
assert!(!dir.path().join(".hololake/tools").exists());
|
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]
|
#[test]
|
||||||
fn execute_openai_tool_calls_repairs_json5_arguments() {
|
fn execute_openai_tool_calls_repairs_json5_arguments() {
|
||||||
let dir = tempfile::tempdir().unwrap();
|
let dir = tempfile::tempdir().unwrap();
|
||||||
|
|||||||
@ -218,23 +218,89 @@ where
|
|||||||
if crate::ai_model_tools::openai_payload_streams(&payload) {
|
if crate::ai_model_tools::openai_payload_streams(&payload) {
|
||||||
return send_openai_stream(request, endpoint, payload, emit);
|
return send_openai_stream(request, endpoint, payload, emit);
|
||||||
}
|
}
|
||||||
let json = send_json_request(request, endpoint, payload)?;
|
|
||||||
if let Some(tool_summary) =
|
run_openai_agent_loop(request, payload, emit, |payload| {
|
||||||
crate::ai_model_tools::execute_openai_tool_calls(request, &json, &mut *emit)?
|
send_json_request(request, endpoint.clone(), payload)
|
||||||
{
|
})
|
||||||
emit(AiAgentStreamEvent::ThinkingDelta {
|
}
|
||||||
text: "工具读取完成,正在继续整理回答……".into(),
|
|
||||||
});
|
fn run_openai_agent_loop<F, S>(
|
||||||
let mut continuation = request.clone();
|
request: &AiModelStreamRequest,
|
||||||
continuation.vault_path = None;
|
mut payload: serde_json::Value,
|
||||||
continuation.vault_paths.clear();
|
emit: &mut F,
|
||||||
continuation.message = format!(
|
mut send: S,
|
||||||
"{}\n\nThe requested tool work completed successfully. Continue the same conversation and answer the user using this result:\n{}",
|
) -> Result<String, String>
|
||||||
request.message, tool_summary,
|
where
|
||||||
);
|
F: FnMut(AiAgentStreamEvent),
|
||||||
return send_openai_compatible_message(&continuation, emit);
|
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>(
|
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]
|
#[test]
|
||||||
fn parses_openai_sse_text_deltas_without_treating_done_as_text() {
|
fn parses_openai_sse_text_deltas_without_treating_done_as_text() {
|
||||||
assert_eq!(
|
assert_eq!(
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user