Skip to content
60 changes: 52 additions & 8 deletions src/google/adk/flows/llm_flows/contents.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,9 @@

logger = logging.getLogger('google_adk.' + __name__)

# Error response for orphaned function calls (issue #3971)
_ORPHANED_CALL_ERROR_RESPONSE = {'error': 'Tool execution was interrupted.'}


class _ContentLlmRequestProcessor(BaseLlmRequestProcessor):
"""Builds the contents for the LLM request."""
Expand Down Expand Up @@ -98,10 +101,44 @@ async def run_async(
request_processor = _ContentLlmRequestProcessor()


def _create_synthetic_response_for_orphaned_calls(
event: Event,
orphaned_calls: list[types.FunctionCall],
) -> Event:
"""Create synthetic error responses for orphaned function calls."""
error_response = _ORPHANED_CALL_ERROR_RESPONSE
parts: list[types.Part] = []

for func_call in orphaned_calls:
logger.warning(
'Auto-healing orphaned function_call (id=%s, name=%s). '
'This indicates execution was interrupted before tool completion.',
func_call.id,
func_call.name,
)
part = types.Part.from_function_response(
name=func_call.name,
response=error_response,
)
part.function_response.id = func_call.id
parts.append(part)

return Event(
invocation_id=event.invocation_id,
author='user',
content=types.Content(role='user', parts=parts),
branch=event.branch,
)


def _rearrange_events_for_async_function_responses_in_history(
events: list[Event],
) -> list[Event]:
"""Rearrange the async function_response events in the history."""
"""Rearrange the async function_response events in the history.

Also auto-heals orphaned function_calls by injecting synthetic error
responses to prevent crash loops (issue #3971).
"""

function_call_id_to_response_events_index: dict[str, int] = {}
for i, event in enumerate(events):
Expand All @@ -117,27 +154,33 @@ def _rearrange_events_for_async_function_responses_in_history(
# function_response should be handled together with function_call below.
continue
elif event.get_function_calls():

function_response_events_indices = set()
orphaned_calls: list[types.FunctionCall] = []
for function_call in event.get_function_calls():
function_call_id = function_call.id
if function_call_id in function_call_id_to_response_events_index:
function_response_events_indices.add(
function_call_id_to_response_events_index[function_call_id]
)
elif function_call_id and not (
event.long_running_tool_ids
and function_call_id in event.long_running_tool_ids
):
orphaned_calls.append(function_call)
result_events.append(event)
if not function_response_events_indices:
if not function_response_events_indices and not orphaned_calls:
continue
if len(function_response_events_indices) == 1:
result_events.append(
events[next(iter(function_response_events_indices))]
)
else: # Merge all async function_response as one response event
if function_response_events_indices:
result_events.append(
_merge_function_response_events(
[events[i] for i in sorted(function_response_events_indices)]
)
)
# Inject synthetic responses for orphaned calls (issue #3971)
if orphaned_calls:
result_events.append(
_create_synthetic_response_for_orphaned_calls(event, orphaned_calls)
)
continue
else:
result_events.append(event)
Expand Down Expand Up @@ -524,6 +567,7 @@ def _get_contents(
result_events = _rearrange_events_for_latest_function_response(
filtered_events
)
# Auto-heal orphaned function_calls to prevent crash loop (issue #3971)
result_events = _rearrange_events_for_async_function_responses_in_history(
result_events
)
Expand Down
8 changes: 6 additions & 2 deletions tests/unittests/apps/test_compaction.py
Original file line number Diff line number Diff line change
Expand Up @@ -1193,7 +1193,9 @@ async def test_sliding_window_pending_function_call_remains_in_contents(
result_contents[1].parts[0].function_call.name,
'tool',
)
self.assertEqual(result_contents[2].parts[0].text, 'e3')
# Auto-healing injects a synthetic response for the orphaned call
self.assertIsNotNone(result_contents[2].parts[0].function_response)
self.assertEqual(result_contents[3].parts[0].text, 'e3')

async def test_token_threshold_excludes_pending_function_call_events(self):
"""Token-threshold compaction stays contiguous before pending calls."""
Expand Down Expand Up @@ -1278,7 +1280,9 @@ async def test_token_threshold_pending_function_call_remains_in_contents(
result_contents[1].parts[0].function_call.name,
'tool',
)
self.assertEqual(result_contents[2].parts[0].text, 'e3')
# Auto-healing injects a synthetic response for the orphaned call
self.assertIsNotNone(result_contents[2].parts[0].function_response)
self.assertEqual(result_contents[3].parts[0].text, 'e3')

async def test_completed_function_call_pair_is_still_compacted(self):
"""Completed function call/response pairs must still be compacted."""
Expand Down
Loading
Loading