Skip to content

Commit d62f9c8

Browse files
xuanyang15copybara-github
authored andcommitted
chore: Always skip executing partial function calls
Related: #4159 Co-authored-by: Xuan Yang <xygoogle@google.com> PiperOrigin-RevId: 859184844
1 parent 7b25b8f commit d62f9c8

File tree

2 files changed

+146
-10
lines changed

2 files changed

+146
-10
lines changed

src/google/adk/flows/llm_flows/base_llm_flow.py

Lines changed: 5 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -38,8 +38,6 @@
3838
from ...agents.run_config import StreamingMode
3939
from ...agents.transcription_entry import TranscriptionEntry
4040
from ...events.event import Event
41-
from ...features import FeatureName
42-
from ...features import is_feature_enabled
4341
from ...models.base_llm_connection import BaseLlmConnection
4442
from ...models.llm_request import LlmRequest
4543
from ...models.llm_response import LlmResponse
@@ -551,14 +549,11 @@ async def _postprocess_async(
551549
# Handles function calls.
552550
if model_response_event.get_function_calls():
553551

554-
if is_feature_enabled(FeatureName.PROGRESSIVE_SSE_STREAMING):
555-
# In progressive SSE streaming mode stage 1, we skip partial FC events
556-
# Only execute FCs in the final aggregated event (partial=False)
557-
if (
558-
invocation_context.run_config.streaming_mode == StreamingMode.SSE
559-
and model_response_event.partial
560-
):
561-
return
552+
# Skip partial function call events - they should not trigger execution
553+
# since partial events are not saved to session (see runners.py).
554+
# Only execute function calls in the non-partial events.
555+
if model_response_event.partial:
556+
return
562557

563558
async with Aclosing(
564559
self._postprocess_handle_function_calls_async(

tests/unittests/flows/llm_flows/test_progressive_sse_streaming.py

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -631,3 +631,144 @@ async def process():
631631
args = fc_part.function_call.args
632632
assert args["num"] == 100
633633
assert args["s"] == "ADK"
634+
635+
636+
class PartialFunctionCallMockModel(BaseLlm):
637+
"""A mock model that yields partial function call events followed by final."""
638+
639+
model: str = "partial-fc-mock"
640+
tool_call_count: int = 0
641+
642+
@classmethod
643+
def supported_models(cls) -> list[str]:
644+
return ["partial-fc-mock"]
645+
646+
async def generate_content_async(
647+
self, llm_request: LlmRequest, stream: bool = False
648+
) -> AsyncGenerator[LlmResponse, None]:
649+
"""Yield partial FC events then final, simulating streaming behavior."""
650+
651+
# Check if this is a follow-up call (after function response)
652+
has_function_response = False
653+
for content in llm_request.contents:
654+
for part in content.parts or []:
655+
if part.function_response:
656+
has_function_response = True
657+
break
658+
659+
if has_function_response:
660+
# Final response after function execution
661+
yield LlmResponse(
662+
content=types.Content(
663+
role="model",
664+
parts=[types.Part.from_text(text="Function executed once.")],
665+
),
666+
partial=False,
667+
)
668+
return
669+
670+
# First call: yield partial FC events then final
671+
# Partial event 1
672+
yield LlmResponse(
673+
content=types.Content(
674+
role="model",
675+
parts=[
676+
types.Part.from_function_call(
677+
name="track_execution", args={"call_id": "partial_1"}
678+
)
679+
],
680+
),
681+
partial=True,
682+
)
683+
684+
# Partial event 2
685+
yield LlmResponse(
686+
content=types.Content(
687+
role="model",
688+
parts=[
689+
types.Part.from_function_call(
690+
name="track_execution", args={"call_id": "partial_2"}
691+
)
692+
],
693+
),
694+
partial=True,
695+
)
696+
697+
# Final aggregated event (only this should trigger execution)
698+
yield LlmResponse(
699+
content=types.Content(
700+
role="model",
701+
parts=[
702+
types.Part.from_function_call(
703+
name="track_execution", args={"call_id": "final"}
704+
)
705+
],
706+
),
707+
partial=False,
708+
finish_reason=types.FinishReason.STOP,
709+
)
710+
711+
712+
def test_partial_function_calls_not_executed_in_none_streaming_mode():
713+
"""Test that partial function call events are skipped regardless of mode."""
714+
execution_log = []
715+
716+
def track_execution(call_id: str) -> str:
717+
"""A tool that logs each execution to verify call count."""
718+
execution_log.append(call_id)
719+
return f"Executed: {call_id}"
720+
721+
mock_model = PartialFunctionCallMockModel()
722+
723+
agent = Agent(
724+
name="partial_fc_test_agent",
725+
model=mock_model,
726+
tools=[track_execution],
727+
)
728+
729+
# Use StreamingMode.NONE to verify partial FCs are still skipped
730+
run_config = RunConfig(streaming_mode=StreamingMode.NONE)
731+
732+
runner = InMemoryRunner(agent=agent)
733+
734+
session = runner.session_service.create_session_sync(
735+
app_name=runner.app_name, user_id="test_user"
736+
)
737+
738+
events = []
739+
for event in runner.run(
740+
user_id="test_user",
741+
session_id=session.id,
742+
new_message=types.Content(
743+
role="user",
744+
parts=[types.Part.from_text(text="Test partial FC handling")],
745+
),
746+
run_config=run_config,
747+
):
748+
events.append(event)
749+
750+
# Verify the tool was only executed once (from the final event)
751+
assert (
752+
len(execution_log) == 1
753+
), f"Expected 1 execution, got {len(execution_log)}: {execution_log}"
754+
assert (
755+
execution_log[0] == "final"
756+
), f"Expected 'final' execution, got: {execution_log[0]}"
757+
758+
# Verify partial events were yielded but not executed
759+
partial_events = [e for e in events if e.partial]
760+
assert (
761+
len(partial_events) == 2
762+
), f"Expected 2 partial events, got {len(partial_events)}"
763+
764+
# Verify there's a function response event (from the final FC execution)
765+
function_response_events = [
766+
e
767+
for e in events
768+
if e.content
769+
and e.content.parts
770+
and any(p.function_response for p in e.content.parts)
771+
]
772+
assert (
773+
len(function_response_events) == 1
774+
), f"Expected 1 function response event, got {len(function_response_events)}"

0 commit comments

Comments
 (0)