diff --git a/crates/g3-core/src/lib.rs b/crates/g3-core/src/lib.rs index 69a702a..1fb9105 100644 --- a/crates/g3-core/src/lib.rs +++ b/crates/g3-core/src/lib.rs @@ -244,13 +244,19 @@ impl StreamingToolParser { self.text_buffer.push_str(&chunk.content); } - // Handle native tool calls + // Handle native tool calls - return them immediately when received + // This allows tools to be executed as soon as they're fully parsed, + // preventing duplicate tool calls from being accumulated if let Some(ref tool_calls) = chunk.tool_calls { debug!("Received native tool calls: {:?}", tool_calls); - // Accumulate native tool calls + // Convert and return tool calls immediately for tool_call in tool_calls { - self.native_tool_calls.push(tool_call.clone()); + let converted_tool = ToolCall { + tool: tool_call.tool.clone(), + args: tool_call.args.clone(), + }; + completed_tools.push(converted_tool); } } @@ -260,25 +266,6 @@ impl StreamingToolParser { debug!("Message finished, processing accumulated tool calls"); } - // If we have native tool calls and the message is stopped, return them - if self.message_stopped && !self.native_tool_calls.is_empty() { - debug!( - "Converting {} native tool calls", - self.native_tool_calls.len() - ); - - for native_tool in &self.native_tool_calls { - let converted_tool = ToolCall { - tool: native_tool.tool.clone(), - args: native_tool.args.clone(), - }; - completed_tools.push(converted_tool); - } - - // Clear native tool calls after processing - self.native_tool_calls.clear(); - } - // Fallback: Try to parse JSON tool calls from text if no native tool calls if completed_tools.is_empty() && !chunk.content.is_empty() { if let Some(json_tool) = self.try_parse_json_tool_call(&chunk.content) { @@ -3638,6 +3625,11 @@ impl Agent { let mut iteration_count = 0; const MAX_ITERATIONS: usize = 400; // Prevent infinite loops let mut response_started = false; + let mut any_tool_executed = false; // Track if ANY tool was executed across all iterations + let mut auto_summary_attempts = 0; // Track auto-summary prompt attempts + const MAX_AUTO_SUMMARY_ATTEMPTS: usize = 2; // Limit auto-summary retries + let mut last_action_was_tool = false; // Track if the last action was a tool call (vs text response) + let mut any_text_response = false; // Track if LLM ever provided a text response // Check if we need to summarize before starting if self.context_window.should_summarize() { @@ -4369,6 +4361,8 @@ impl Agent { // 2. At the end when no tools were executed (handled in the "no tool executed" branch) tool_executed = true; + any_tool_executed = true; // Track across all iterations + last_action_was_tool = true; // Last action was a tool call // Reset the JSON tool call filter state after each tool execution // This ensures the filter doesn't stay in suppression mode for subsequent streaming content @@ -4416,6 +4410,8 @@ impl Agent { self.ui_writer.print_agent_response(&filtered_content); self.ui_writer.flush(); current_response.push_str(&filtered_content); + last_action_was_tool = false; // Text response received + any_text_response = true; } } } @@ -4672,10 +4668,48 @@ impl Agent { let has_response = !current_response.is_empty() || !full_response.is_empty(); if !has_response { - warn!( - "Loop exited without any response after {} iterations", - iteration_count - ); + if any_tool_executed && last_action_was_tool && !any_text_response { + // Only auto-prompt for summary if: + // 1. Tools were executed in previous iterations + // 2. The last action was a tool call (not a text response) + // 3. No text response was ever provided by the LLM + if auto_summary_attempts < MAX_AUTO_SUMMARY_ATTEMPTS { + // Auto-prompt for a summary by adding a follow-up message + auto_summary_attempts += 1; + warn!( + "LLM stopped without final response after executing tools ({} iterations, auto-summary attempt {})", + iteration_count, auto_summary_attempts + ); + self.ui_writer.print_context_status( + "\n🔄 Model stopped without response. Auto-prompting for summary...\n" + ); + + // Add a follow-up message asking for summary + let summary_prompt = Message::new( + MessageRole::User, + "Please provide a brief summary of what was accomplished and any next steps.".to_string(), + ); + self.context_window.add_message(summary_prompt); + request.messages = self.context_window.conversation_history.clone(); + + // Continue the loop to get the summary + continue; + } else { + // Max auto-summary attempts reached, give up gracefully + warn!( + "Max auto-summary attempts ({}) reached, returning without summary", + MAX_AUTO_SUMMARY_ATTEMPTS + ); + self.ui_writer.print_agent_response( + "\n⚠️ The model stopped without providing a final response after multiple attempts.\n" + ); + } + } else { + warn!( + "Loop exited without any response after {} iterations", + iteration_count + ); + } } else { // Only set full_response if it's empty (first iteration without tools) // This prevents duplication when the agent responds without calling final_output diff --git a/crates/g3-providers/src/anthropic.rs b/crates/g3-providers/src/anthropic.rs index d025864..e84bfe1 100644 --- a/crates/g3-providers/src/anthropic.rs +++ b/crates/g3-providers/src/anthropic.rs @@ -607,6 +607,8 @@ impl AnthropicProvider { debug!("Receiver dropped, stopping stream"); return accumulated_usage; } + // Clear tool calls after sending to prevent duplicates at message_stop + current_tool_calls.clear(); } } "message_stop" => {