fix: Update empty event check to include executable code and execution results

Close #3859
Close #3921

Co-authored-by: George Weale <gweale@google.com>
PiperOrigin-RevId: 852375447
This commit is contained in:
George Weale
2026-01-05 11:04:02 -08:00
committed by Copybara-Service
parent 6f259f08b3
commit 688f48fffb
2 changed files with 196 additions and 8 deletions
+15 -8
View File
@@ -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
@@ -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
)