Repository: lastmile-ai/mcp-agent

Issue:
Gemini tool call issues
I get this error when i have gemini using the meilisearxch MCP tool:

```
[ERROR] 2025-06-24T20:08:45 mcp_agent.executor.executor - Error executing task:
2025-06-24T20:08:45.884138530Z app[web.1]: 400 INVALID_ARGUMENT. {'error': {'code': 400, 'message': 'Please ensure that the
2025-06-24T20:08:45.884141166Z app[web.1]: number of function response parts is equal to the number of function call parts
2025-06-24T20:08:45.884143769Z app[web.1]: of the function call turn.', 'status': 'INVALID_ARGUMENT'}} 
```

and am wondering where this is coming from, for there should not be more of one or the other, unless something in the async logic is breaking? Would appreciate any help!


Discussion:
<head></head><h1 style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;">Why you’re seeing<span class="Apple-converted-space"> </span><code inline="">400 INVALID_ARGUMENT</code></h1><p style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;">Gemini rejects your<span class="Apple-converted-space"> </span><strong>tool-response</strong><span class="Apple-converted-space"> </span>message when it doesn’t match a strict rule:</p><blockquote style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;"><p>For every<span class="Apple-converted-space"> </span><code inline="">functionCall</code><span class="Apple-converted-space"> </span>part the model produced in the<span class="Apple-converted-space"> </span><strong>previous turn</strong>, you must return<span class="Apple-converted-space"> </span><strong>exactly one</strong>matching<span class="Apple-converted-space"> </span><code inline="">functionResponse</code><span class="Apple-converted-space"> </span>part in the<span class="Apple-converted-space"> </span><strong>very next turn</strong>—all inside a single message.</p></blockquote><hr style="font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;"><h2 style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;">Correct pattern</h2><pre style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;"><code class="language-python"># Gemini reply ➜ yields two tool calls
calls = response.candidates[0].function_calls    # len == 2

# Build ONE message with TWO parts
parts = [
    Part.from_function_response(
        name=calls[0].name,
        response={"result": result_0},
    ),
    Part.from_function_response(
        name=calls[1].name,
        response={"result": result_1},
    ),
]
tool_response = Content(role="user", parts=parts)

chat.send_message(tool_response)   # one HTTP request, not two
</code></pre><hr style="font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;"><h2 style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;">Where things usually go wrong</h2>
What often happens | Why Gemini throws the error
-- | --
You loop through function_calls and send one request per result | Gemini sees only the first result → mismatch
You run calls in parallel and append to parts, but one job fails or times out | Fewer parts than original calls
You reply in natural language first, planning to send tool results later | A non-tool turn sits between call and response

<hr style="font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;"><h2 style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;">Fixing it in your MCP tool</h2><ol style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;"><li><p><strong>Collect all<span class="Apple-converted-space"> </span><code inline="">functionCall</code>s first</strong><span class="Apple-converted-space"> </span>– don’t send anything yet.</p></li><li><p><strong>Execute the calls</strong><span class="Apple-converted-space"> </span>(in parallel if you like) and capture every result.</p></li><li><p><strong>Create one<span class="Apple-converted-space"> </span><code inline="">Content(role="user", parts=[...])</code><span class="Apple-converted-space"> </span>object</strong><span class="Apple-converted-space"> </span>whose length equals<span class="Apple-converted-space"> </span><code inline="">len(function_calls)</code>.</p></li><li><p><strong>Send that single message</strong><span class="Apple-converted-space"> </span>and wait for Gemini’s normal reply.</p></li></ol><p style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;">If any tool fails, either:</p><ul style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;"><li><p>include a synthetic error payload inside its<span class="Apple-converted-space"> </span><code inline="">functionResponse</code>,<span class="Apple-converted-space"> </span><strong>or</strong></p></li><li><p>abort the whole batch and surface the error back to Gemini.</p></li></ul><hr style="font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;"><h2 style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;">Quick checklist</h2><ul class="contains-task-list" style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;"><li class="task-list-item"><p><input type="checkbox" disabled=""><span class="Apple-converted-space"> </span>Next request contains<span class="Apple-converted-space"> </span><strong>exactly</strong><span class="Apple-converted-space"> </span>the same number of<span class="Apple-converted-space"> </span><code inline="">functionResponse</code><span class="Apple-converted-space"> </span>parts</p></li><li class="task-list-item"><p><input type="checkbox" disabled=""><span class="Apple-converted-space"> </span>All parts bundled in<span class="Apple-converted-space"> </span><strong>one</strong><span class="Apple-converted-space"> </span>message</p></li><li class="task-list-item"><p><input type="checkbox" disabled=""><span class="Apple-converted-space"> </span><code inline="">name</code><span class="Apple-converted-space"> </span>fields match the original calls</p></li><li class="task-list-item"><p><input type="checkbox" disabled=""><span class="Apple-converted-space"> </span>No intermediary “thinking” messages between the call and its answer</p></li></ul><p style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;">Tick all four and the<span class="Apple-converted-space"> </span><code inline="">400 INVALID_ARGUMENT</code><span class="Apple-converted-space"> </span>should disappear.</p><hr style="font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;"><h3 style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;">Why Gemini-Flash triggers this more often</h3><p style="caret-color: rgb(0, 0, 0); color: rgb(0, 0, 0); font-style: normal; font-variant-caps: normal; font-weight: 400; letter-spacing: normal; orphans: auto; text-align: start; text-indent: 0px; text-transform: none; white-space: normal; widows: auto; word-spacing: 0px; -webkit-text-stroke-width: 0px; text-decoration: none;"><code inline="">gemini-1.5-flash</code><span class="Apple-converted-space"> </span>likes calling multiple tools in parallel, unlike<span class="Apple-converted-space"> </span><code inline="">gemini-pro</code>, which often calls one at a time. That’s why the issue surfaced only after switching models.</p>
Quickfix would be to remove default parallelization in the arguments in GoogleAugmentedLLM?
Hi @rahuldave, thanks for flagging the bug! I have a fix on the way. In the meanwhile, yes running the tool calls sequentially should work as a temporary workaround.
Thanks!!



Patch:
diff --git a/src/mcp_agent/workflows/llm/augmented_llm_google.py b/src/mcp_agent/workflows/llm/augmented_llm_google.py
index d07e2cbd1..2d3783cb2 100644
--- a/src/mcp_agent/workflows/llm/augmented_llm_google.py
+++ b/src/mcp_agent/workflows/llm/augmented_llm_google.py
@@ -165,20 +165,36 @@ async def generate(self, message, request_params: RequestParams | None = None):
             ]
 
             if function_calls:
-                results = await self.executor.execute_many(function_calls)
+                results: list[
+                    types.Content | BaseException | None
+                ] = await self.executor.execute_many(function_calls)
 
                 self.logger.debug(
                     f"Iteration {i}: Tool call results: {str(results) if results else 'None'}"
                 )
 
+                function_response_parts: list[types.Part] = []
                 for result in results:
-                    if isinstance(result, BaseException):
+                    if (
+                        result
+                        and not isinstance(result, BaseException)
+                        and result.parts
+                    ):
+                        function_response_parts.extend(result.parts)
+                    else:
                         self.logger.error(
-                            f"Warning: Unexpected error during tool execution: {result}. Continuing.."
+                            f"Warning: Unexpected error during tool execution: {result}. Continuing..."
                         )
-                        break
-                    if result is not None:
-                        messages.append(result)
+                        function_response_parts.append(
+                            types.Part.from_text(text=f"Error executing tool: {result}")
+                        )
+
+                # Combine all parallel function responses into a single message
+                if function_response_parts:
+                    function_response_content = types.Content(
+                        role="tool", parts=function_response_parts
+                    )
+                    messages.append(function_response_content)
             else:
                 self.logger.debug(
                     f"Iteration {i}: Stopping because finish_reason is '{candidate.finish_reason}'"


Test patch:
diff --git a/tests/workflows/llm/test_augmented_llm_google.py b/tests/workflows/llm/test_augmented_llm_google.py
index abd7fdc39..3e63e01e5 100644
--- a/tests/workflows/llm/test_augmented_llm_google.py
+++ b/tests/workflows/llm/test_augmented_llm_google.py
@@ -793,3 +793,127 @@ class TestResponseModel(BaseModel):
             assert isinstance(result, TestResponseModel)
             assert result.name == "MixedTypes"
             assert result.value == 123
+
+    @pytest.mark.asyncio
+    async def test_parallel_tool_calls(self, mock_llm: GoogleAugmentedLLM):
+        """
+        Tests that parallel tool calls return a single Content with multiple function response parts.
+        """
+        from google.genai import types
+
+        parallel_tool_response = types.GenerateContentResponse(
+            candidates=[
+                types.Candidate(
+                    content=types.Content(
+                        role="model",
+                        parts=[
+                            types.Part(
+                                function_call=types.FunctionCall(
+                                    name="tool1", args={"param": "value1"}, id="call_1"
+                                )
+                            ),
+                            types.Part(
+                                function_call=types.FunctionCall(
+                                    name="tool2", args={"param": "value2"}, id="call_2"
+                                )
+                            ),
+                        ],
+                    ),
+                    finish_reason="STOP",
+                )
+            ]
+        )
+
+        final_response = self.create_text_response(
+            "Final response after parallel tools"
+        )
+
+        mock_llm.executor.execute = AsyncMock(
+            side_effect=[parallel_tool_response, final_response]
+        )
+
+        async def mock_execute_tool_call(function_call):
+            if function_call.name == "tool1":
+                return types.Content(
+                    role="tool",
+                    parts=[
+                        types.Part.from_function_response(
+                            name="tool1", response={"result": "Result from tool 1"}
+                        )
+                    ],
+                )
+            elif function_call.name == "tool2":
+                return types.Content(
+                    role="tool",
+                    parts=[
+                        types.Part.from_function_response(
+                            name="tool2", response={"result": "Result from tool 2"}
+                        )
+                    ],
+                )
+
+        mock_llm.execute_tool_call = AsyncMock(side_effect=mock_execute_tool_call)
+
+        mock_llm.executor.execute_many = AsyncMock(
+            return_value=[
+                types.Content(
+                    role="tool",
+                    parts=[
+                        types.Part.from_function_response(
+                            name="tool1", response={"result": "Result from tool 1"}
+                        )
+                    ],
+                ),
+                types.Content(
+                    role="tool",
+                    parts=[
+                        types.Part.from_function_response(
+                            name="tool2", response={"result": "Result from tool 2"}
+                        )
+                    ],
+                ),
+            ]
+        )
+
+        # Track the messages to verify our fix combines tool responses correctly
+        original_messages = []
+
+        def track_messages(messages):
+            original_messages.extend(messages)
+            return messages
+
+        mock_llm.history.set = MagicMock(side_effect=track_messages)
+
+        responses = await mock_llm.generate("Test parallel tool calls")
+
+        # Verify the responses
+        assert len(responses) == 2  # Tool call response + final response
+        assert len(responses[0].parts) == 2  # Two parallel tool calls
+        assert responses[0].parts[0].function_call.name == "tool1"
+        assert responses[0].parts[1].function_call.name == "tool2"
+        assert responses[1].parts[0].text == "Final response after parallel tools"
+
+        # Verify that only ONE tool response message was added to messages
+        tool_messages = [
+            msg
+            for msg in original_messages
+            if hasattr(msg, "role") and msg.role == "tool"
+        ]
+        assert len(tool_messages) == 1, (
+            f"Expected 1 tool message, got {len(tool_messages)}"
+        )
+
+        # Verify the single tool message contains both function responses
+        tool_message = tool_messages[0]
+        assert len(tool_message.parts) == 2, (
+            f"Expected 2 parts in tool message, got {len(tool_message.parts)}"
+        )
+
+        # Verify both tool responses are present in the combined message
+        part_names = [
+            part.function_response.name
+            for part in tool_message.parts
+            if part.function_response
+        ]
+        assert "tool1" in part_names, "tool1 response not found in combined message"
+        assert "tool2" in part_names, "tool2 response not found in combined message"