From 7058f5b23e3d281e061d98f4b656e7f0a8ac6787 Mon Sep 17 00:00:00 2001 From: nyxst4ck Date: Tue, 16 Jun 2026 18:35:10 -0300 Subject: [PATCH] fix(planners): keep all leading parallel function calls PlanReActPlanner.process_planning_response guarded the trailing parallel-call collection loop with `if first_fc_part_index > 0`, but first_fc_part_index is the index of the first function call (initialized to -1). When the model response *begins* with a function call the index is 0, so the guard was false and every parallel function call after the first was silently dropped. Responses that start with text (index >= 1) were unaffected, which is why this went unnoticed. Use `>= 0` so the first part being a function call is handled too. Adds unit tests for both the leading-call and leading-text cases. --- .../adk/planners/plan_re_act_planner.py | 2 +- tests/unittests/planners/__init__.py | 13 +++++ .../planners/test_plan_re_act_planner.py | 58 +++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) create mode 100644 tests/unittests/planners/__init__.py create mode 100644 tests/unittests/planners/test_plan_re_act_planner.py diff --git a/src/google/adk/planners/plan_re_act_planner.py b/src/google/adk/planners/plan_re_act_planner.py index dab3a1fecb..48ca41bb21 100644 --- a/src/google/adk/planners/plan_re_act_planner.py +++ b/src/google/adk/planners/plan_re_act_planner.py @@ -71,7 +71,7 @@ def process_planning_response( # Split the response into reasoning and final answer parts. self._handle_non_function_call_parts(response_parts[i], preserved_parts) - if first_fc_part_index > 0: + if first_fc_part_index >= 0: j = first_fc_part_index + 1 while j < len(response_parts): if response_parts[j].function_call: diff --git a/tests/unittests/planners/__init__.py b/tests/unittests/planners/__init__.py new file mode 100644 index 0000000000..58d482ea38 --- /dev/null +++ b/tests/unittests/planners/__init__.py @@ -0,0 +1,13 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. diff --git a/tests/unittests/planners/test_plan_re_act_planner.py b/tests/unittests/planners/test_plan_re_act_planner.py new file mode 100644 index 0000000000..ccafdf48a9 --- /dev/null +++ b/tests/unittests/planners/test_plan_re_act_planner.py @@ -0,0 +1,58 @@ +# Copyright 2026 Google LLC +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Tests for PlanReActPlanner.process_planning_response.""" + +from google.adk.planners.plan_re_act_planner import PlanReActPlanner +from google.genai import types + + +def _function_call_names(parts): + return [p.function_call.name for p in parts if p.function_call] + + +def test_preserves_all_leading_parallel_function_calls(): + """Parallel function calls at the start of the response must all survive. + + Regression test: the trailing-group guard used ``> 0``, so when the first + part was a function call (index 0) the loop that collects the rest of the + parallel call group never ran and every call after the first was dropped. + """ + planner = PlanReActPlanner() + response_parts = [ + types.Part.from_function_call(name="get_weather", args={"city": "SF"}), + types.Part.from_function_call(name="get_time", args={"city": "SF"}), + ] + + result = planner.process_planning_response( + callback_context=None, response_parts=response_parts + ) + + assert _function_call_names(result) == ["get_weather", "get_time"] + + +def test_preserves_parallel_function_calls_after_leading_text(): + """The same parallel group is preserved when text comes first.""" + planner = PlanReActPlanner() + response_parts = [ + types.Part(text="Let me look that up."), + types.Part.from_function_call(name="get_weather", args={"city": "SF"}), + types.Part.from_function_call(name="get_time", args={"city": "SF"}), + ] + + result = planner.process_planning_response( + callback_context=None, response_parts=response_parts + ) + + assert _function_call_names(result) == ["get_weather", "get_time"]