fix: Don't terminate agent loops early for null tool output
CI / All (ubuntu-latest) (push) Failing after 26s
CI / All (macos-latest) (push) Has been cancelled
CI / All (windows-latest) (push) Has been cancelled

This commit is contained in:
2026-07-17 16:17:29 -06:00
parent c979041161
commit 058810137c
+30 -12
View File
@@ -144,7 +144,6 @@ pub async fn eval_tool_calls(
if calls.is_empty() { if calls.is_empty() {
bail!("The request was aborted because an infinite loop of function calls was detected.") bail!("The request was aborted because an infinite loop of function calls was detected.")
} }
let mut is_all_null = true;
for call in calls { for call in calls {
if let Some(msg) = ctx.tool_scope.tool_tracker.check_loop(&call.clone()) { if let Some(msg) = ctx.tool_scope.tool_tracker.check_loop(&call.clone()) {
let dup_msg = format!("{{\"tool_call_loop_alert\":{}}}", msg.trim()); let dup_msg = format!("{{\"tool_call_loop_alert\":{}}}", msg.trim());
@@ -154,19 +153,10 @@ pub async fn eval_tool_calls(
); );
let val = json!(dup_msg); let val = json!(dup_msg);
output.push(ToolResult::new(call, val)); output.push(ToolResult::new(call, val));
is_all_null = false;
continue; continue;
} }
let mut result = call.eval(ctx).await?; let result = call.eval(ctx).await?;
if result.is_null() { output.push(ToolResult::new(call, normalize_tool_result(result)));
result = json!("DONE");
} else {
is_all_null = false;
}
output.push(ToolResult::new(call, result));
}
if is_all_null {
output = vec![];
} }
if !output.is_empty() { if !output.is_empty() {
@@ -196,6 +186,19 @@ pub async fn eval_tool_calls(
Ok(output) Ok(output)
} }
/// Tools that succeed silently (e.g. `mkdir -p` via execute_command) evaluate to
/// `Null`. Substitute a concrete `"DONE"` marker so every call produces a
/// `ToolResult`: agentic loops (graph llm nodes, spawned agents, the REPL) treat
/// an empty `tool_results` as "the LLM concluded", so dropping silent results
/// would prematurely terminate a turn that called only silent tools.
fn normalize_tool_result(result: Value) -> Value {
if result.is_null() {
json!("DONE")
} else {
result
}
}
#[derive(Debug, Clone, Deserialize, Serialize)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct ToolResult { pub struct ToolResult {
pub call: ToolCall, pub call: ToolCall,
@@ -1527,6 +1530,21 @@ mod tests {
ToolCall::new(name.to_string(), args, Some("id1".to_string())) ToolCall::new(name.to_string(), args, Some("id1".to_string()))
} }
#[test]
fn normalize_tool_result_substitutes_done_for_null() {
assert_eq!(normalize_tool_result(Value::Null), json!("DONE"));
}
#[test]
fn normalize_tool_result_preserves_non_null_values() {
assert_eq!(
normalize_tool_result(json!({"output": "hi"})),
json!({"output": "hi"})
);
assert_eq!(normalize_tool_result(json!("")), json!(""));
assert_eq!(normalize_tool_result(json!(false)), json!(false));
}
#[test] #[test]
fn toolcall_new_sets_fields() { fn toolcall_new_sets_fields() {
let tc = ToolCall::new("my_tool".into(), json!({"x": 1}), Some("call-1".into())); let tc = ToolCall::new("my_tool".into(), json!({"x": 1}), Some("call-1".into()));