diff --git a/agent/conversation_loop.py b/agent/conversation_loop.py index a628093e1f7b..c4d6b24295f5 100644 --- a/agent/conversation_loop.py +++ b/agent/conversation_loop.py @@ -4411,34 +4411,48 @@ def _perform_api_call(next_api_kwargs): pass # Check for incomplete (opened but never closed) - # This means the model ran out of output tokens mid-reasoning — retry up to 2 times - if has_incomplete_scratchpad(assistant_message.content or ""): + # Recover structured calls; retry text-only truncation up to 2 times. + incomplete_scratchpad = has_incomplete_scratchpad(assistant_message.content or "") + if incomplete_scratchpad and assistant_message.tool_calls: + # A structured tool call is represented independently even + # when the adjacent free-form reasoning was truncated. Drop + # the unclosed tail before persistence/replay, then use the + # normal validation and approval path. + visible_prefix, _, _ = assistant_message.content.partition( + "" + ) + assistant_message.content = visible_prefix.rstrip() + agent._buffer_vprint( + "⚠️ Incomplete detected; " + f"recovering {len(assistant_message.tool_calls)} structured tool call(s)" + ) + elif incomplete_scratchpad: agent._incomplete_scratchpad_retries += 1 - + agent._buffer_vprint("⚠️ Incomplete detected (opened but never closed)") - + if agent._incomplete_scratchpad_retries <= 2: agent._buffer_vprint(f"🔄 Retrying API call ({agent._incomplete_scratchpad_retries}/2)...") # Don't add the broken message, just retry continue - else: - # Max retries - discard this turn and save as partial - agent._flush_status_buffer() - agent._vprint(f"{agent.log_prefix}❌ Max retries (2) for incomplete scratchpad. Saving as partial.", force=True) - agent._incomplete_scratchpad_retries = 0 - - rolled_back_messages = agent._get_messages_up_to_last_assistant(messages) - agent._cleanup_task_resources(effective_task_id) - agent._persist_session(messages, conversation_history) - - return { - "final_response": "Incomplete REASONING_SCRATCHPAD after 2 retries", - "messages": rolled_back_messages, - "api_calls": api_call_count, - "completed": False, - "partial": True, - "error": "Incomplete REASONING_SCRATCHPAD after 2 retries" - } + + # Max retries - discard this turn and save as partial + agent._flush_status_buffer() + agent._vprint(f"{agent.log_prefix}❌ Max retries (2) for incomplete scratchpad. Saving as partial.", force=True) + agent._incomplete_scratchpad_retries = 0 + + rolled_back_messages = agent._get_messages_up_to_last_assistant(messages) + agent._cleanup_task_resources(effective_task_id) + agent._persist_session(messages, conversation_history) + + return { + "final_response": "Incomplete REASONING_SCRATCHPAD after 2 retries", + "messages": rolled_back_messages, + "api_calls": api_call_count, + "completed": False, + "partial": True, + "error": "Incomplete REASONING_SCRATCHPAD after 2 retries" + } # Reset incomplete scratchpad counter on clean response agent._incomplete_scratchpad_retries = 0 diff --git a/tests/run_agent/test_run_agent.py b/tests/run_agent/test_run_agent.py index 13187af94e79..f687da951982 100644 --- a/tests/run_agent/test_run_agent.py +++ b/tests/run_agent/test_run_agent.py @@ -4211,6 +4211,85 @@ def test_tool_call_none_args_verbose_logging_does_not_crash(self, agent): assert result["final_response"] == "Done searching" assert mock_handle_function_call.call_args.args[:2] == ("web_search", {}) + def test_incomplete_scratchpad_preserves_structured_tool_call(self, agent): + self._setup_agent(agent) + tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1") + resp1 = _mock_response( + content="Visible preface\nNeed current data", + finish_reason="tool_calls", + tool_calls=[tc], + ) + resp2 = _mock_response(content="Done searching", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [resp1, resp2] + + with ( + patch("run_agent.handle_function_call", return_value="search result") as mock_handle_function_call, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("search something") + + assert result["final_response"] == "Done searching" + assert result["api_calls"] == 2 + mock_handle_function_call.assert_called_once() + replay_messages = agent.client.chat.completions.create.call_args_list[1].kwargs["messages"] + recovered = next(msg for msg in replay_messages if msg.get("tool_calls")) + assert recovered["content"] == "Visible preface" + assert "REASONING_SCRATCHPAD" not in recovered["content"] + + def test_incomplete_scratchpad_does_not_execute_non_object_tool_call(self, agent): + self._setup_agent(agent) + tc = _mock_tool_call( + name="web_search", + arguments="[]", + call_id="c1", + ) + resp1 = _mock_response( + content="Need current data", + finish_reason="tool_calls", + tool_calls=[tc], + ) + resp2 = _mock_response(content="Recovered", finish_reason="stop") + agent.client.chat.completions.create.side_effect = [resp1, resp2] + + with ( + patch("run_agent.handle_function_call") as mock_handle_function_call, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("search something") + + assert result["final_response"] == "Recovered" + assert result["api_calls"] == 2 + mock_handle_function_call.assert_not_called() + replay_messages = agent.client.chat.completions.create.call_args_list[1].kwargs["messages"] + tool_result = next(msg for msg in replay_messages if msg.get("role") == "tool") + assert "Invalid tool arguments" in tool_result["content"] + + def test_incomplete_scratchpad_without_tool_calls_still_fails_bounded(self, agent): + self._setup_agent(agent) + incomplete = _mock_response( + content="Still thinking", + finish_reason="stop", + ) + agent.client.chat.completions.create.return_value = incomplete + + with ( + patch("run_agent.handle_function_call") as mock_handle_function_call, + patch.object(agent, "_persist_session"), + patch.object(agent, "_save_trajectory"), + patch.object(agent, "_cleanup_task_resources"), + ): + result = agent.run_conversation("hard question") + + assert result["completed"] is False + assert result["partial"] is True + assert result["api_calls"] == 3 + assert agent.client.chat.completions.create.call_count == 3 + mock_handle_function_call.assert_not_called() + def test_request_scoped_api_hooks_fire_for_each_api_call(self, agent): self._setup_agent(agent) tc = _mock_tool_call(name="web_search", arguments="{}", call_id="c1")