diff --git a/src/google/adk/flows/llm_flows/contents.py b/src/google/adk/flows/llm_flows/contents.py index ce0df37e..101b409c 100644 --- a/src/google/adk/flows/llm_flows/contents.py +++ b/src/google/adk/flows/llm_flows/contents.py @@ -220,13 +220,15 @@ def _rearrange_events_for_latest_function_response( def _is_part_invisible(p: types.Part) -> bool: - """A part is considered invisble if it's a thought, or has no visible content.""" + """Returns whether a part is invisible for LLM context.""" return getattr(p, 'thought', False) or not ( p.text or p.inline_data or p.file_data or p.function_call or p.function_response + or p.executable_code + or p.code_execution_result ) @@ -236,9 +238,8 @@ def _contains_empty_content(event: Event) -> bool: This can happen to the events that only changed session state. When both content and transcriptions are empty, the event will be considered as empty. The content is considered empty if none of its parts contain text, - inline data, file data, function call, or function response. Parts with - only thoughts are also considered empty. - + inline data, file data, function call, function response, executable code, or + code execution result. Parts with only thoughts are also considered empty. Args: event: The event to check. @@ -520,7 +521,7 @@ def _present_other_agent_message(event: Event) -> Optional[Event]: if part.thought: # Exclude thoughts from the context. continue - elif part.text: + elif part.text is not None and part.text.strip(): content.parts.append( types.Part(text=f'[{event.author}] said: {part.text}') ) @@ -543,11 +544,17 @@ def _present_other_agent_message(event: Event) -> Optional[Event]: ) ) ) - # Fallback to the original part for non-text and non-functionCall parts. - else: + elif ( + part.inline_data + or part.file_data + or part.executable_code + or part.code_execution_result + ): content.parts.append(part) + else: + continue - # If no meaningful parts were added (only "For context:" remains), return None + # Return None when only "For context:" remains. if len(content.parts) == 1: return None diff --git a/tests/unittests/flows/llm_flows/test_contents.py b/tests/unittests/flows/llm_flows/test_contents.py index bafaebed..dab06394 100644 --- a/tests/unittests/flows/llm_flows/test_contents.py +++ b/tests/unittests/flows/llm_flows/test_contents.py @@ -572,6 +572,38 @@ async def test_events_with_empty_content_are_skipped(): role="user", ), ), + # Event with content that has executable code part + Event( + invocation_id="inv10", + author="test_agent", + content=types.Content( + parts=[ + types.Part( + executable_code=types.ExecutableCode( + code="print('hello')", + language="PYTHON", + ) + ) + ], + role="model", + ), + ), + # Event with content that has code execution result part + Event( + invocation_id="inv11", + author="test_agent", + content=types.Content( + parts=[ + types.Part( + code_execution_result=types.CodeExecutionResult( + outcome="OUTCOME_OK", + output="hello", + ) + ) + ], + role="model", + ), + ), ] invocation_context.session.events = events @@ -608,4 +640,153 @@ async def test_events_with_empty_content_are_skipped(): parts=[types.Part(text=""), types.Part(text="Mixed content")], role="user", ), + types.Content( + parts=[ + types.Part( + executable_code=types.ExecutableCode( + code="print('hello')", + language="PYTHON", + ) + ) + ], + role="model", + ), + types.Content( + parts=[ + types.Part( + code_execution_result=types.CodeExecutionResult( + outcome="OUTCOME_OK", + output="hello", + ) + ) + ], + role="model", + ), ] + + +@pytest.mark.asyncio +async def test_code_execution_result_events_are_not_skipped(): + """Test that events with code execution result are not skipped. + + This is a regression test for the endless loop bug where code executor + outputs were not passed to the LLM because the events were incorrectly + filtered as empty. + """ + agent = Agent(model="gemini-2.5-flash", name="test_agent") + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Write code to calculate factorial"), + ), + # Model generates code + Event( + invocation_id="inv2", + author="test_agent", + content=types.Content( + parts=[ + types.Part(text="Here's the code:"), + types.Part( + executable_code=types.ExecutableCode( + code=( + "def factorial(n):\n return 1 if n <= 1 else n *" + " factorial(n-1)\nprint(factorial(5))" + ), + language="PYTHON", + ) + ), + ], + role="model", + ), + ), + # Code execution result + Event( + invocation_id="inv3", + author="test_agent", + content=types.Content( + parts=[ + types.Part( + code_execution_result=types.CodeExecutionResult( + outcome="OUTCOME_OK", + output="120", + ) + ) + ], + role="model", + ), + ), + ] + invocation_context.session.events = events + + # Process the request + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + # Verify all three events are included, especially the code execution result + assert len(llm_request.contents) == 3 + assert llm_request.contents[0] == types.UserContent( + "Write code to calculate factorial" + ) + # Second event has executable code + assert llm_request.contents[1].parts[1].executable_code is not None + # Third event has code execution result - this was the bug! + assert llm_request.contents[2].parts[0].code_execution_result is not None + assert llm_request.contents[2].parts[0].code_execution_result.output == "120" + + +@pytest.mark.asyncio +async def test_code_execution_result_not_in_first_part_is_not_skipped(): + """Test that code execution results aren't skipped. + + This covers results that appear in a non-first part. + """ + agent = Agent(model="gemini-2.5-flash", name="test_agent") + llm_request = LlmRequest(model="gemini-2.5-flash") + invocation_context = await testing_utils.create_invocation_context( + agent=agent + ) + + events = [ + Event( + invocation_id="inv1", + author="user", + content=types.UserContent("Run some code."), + ), + Event( + invocation_id="inv2", + author="test_agent", + content=types.Content( + parts=[ + types.Part(text=""), + types.Part( + code_execution_result=types.CodeExecutionResult( + outcome="OUTCOME_OK", + output="42", + ) + ), + ], + role="model", + ), + ), + ] + invocation_context.session.events = events + + async for _ in contents.request_processor.run_async( + invocation_context, llm_request + ): + pass + + assert len(llm_request.contents) == 2 + assert any( + part.code_execution_result is not None + and part.code_execution_result.output == "42" + for part in llm_request.contents[1].parts + )