Skip to content

Commit f9ac5d9

Browse files
committed
fix: exclude long-running tools from orphaned call detection
Long-running tools (e.g., human-in-the-loop) intentionally don't produce immediate function_response events. They should not be treated as orphaned. Changes: - Check event.long_running_tool_ids before marking a call as orphaned - Add tests for long-running tool exclusion
1 parent 5c2503e commit f9ac5d9

File tree

2 files changed

+119
-1
lines changed

2 files changed

+119
-1
lines changed

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -141,7 +141,10 @@ def _rearrange_events_for_async_function_responses_in_history(
141141
function_response_events_indices.add(
142142
function_call_id_to_response_events_index[function_call_id]
143143
)
144-
elif function_call_id:
144+
elif function_call_id and not (
145+
event.long_running_tool_ids
146+
and function_call_id in event.long_running_tool_ids
147+
):
145148
orphaned_calls.append(function_call)
146149
result_events.append(event)
147150
if not function_response_events_indices and not orphaned_calls:

tests/unittests/flows/llm_flows/test_contents_function.py

Lines changed: 115 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -867,3 +867,118 @@ async def test_auto_healing_logs_warning(caplog):
867867
and "test_tool" in record.message
868868
for record in caplog.records
869869
)
870+
871+
872+
@pytest.mark.asyncio
873+
async def test_long_running_tool_not_detected_as_orphaned():
874+
"""Test that long-running tool calls are NOT treated as orphaned.
875+
876+
Long-running tools (e.g., human-in-the-loop) intentionally don't produce
877+
immediate function_response events. They should be excluded from orphaned
878+
call detection.
879+
"""
880+
agent = Agent(model="gemini-2.5-flash", name="test_agent")
881+
llm_request = LlmRequest(model="gemini-2.5-flash")
882+
invocation_context = await testing_utils.create_invocation_context(
883+
agent=agent
884+
)
885+
886+
long_running_call = types.FunctionCall(
887+
id="long_running_123", name="request_human_approval", args={}
888+
)
889+
890+
events = [
891+
Event(
892+
invocation_id="inv1",
893+
author="user",
894+
content=types.UserContent("Please approve this action"),
895+
),
896+
Event(
897+
invocation_id="inv2",
898+
author="test_agent",
899+
content=types.ModelContent(
900+
[types.Part(function_call=long_running_call)]
901+
),
902+
long_running_tool_ids={"long_running_123"},
903+
),
904+
]
905+
invocation_context.session.events = events
906+
907+
async for _ in contents.request_processor.run_async(
908+
invocation_context, llm_request
909+
):
910+
pass
911+
912+
# Verify NO synthetic error response was injected
913+
# Should only have 2 contents: user message + function call
914+
assert len(llm_request.contents) == 2
915+
916+
# Verify no synthetic error in any content
917+
for content in llm_request.contents:
918+
for part in content.parts:
919+
if part.function_response:
920+
assert part.function_response.response != contents._ORPHANED_CALL_ERROR_RESPONSE, (
921+
"Long-running tool should not be treated as orphaned"
922+
)
923+
924+
925+
@pytest.mark.asyncio
926+
async def test_mixed_long_running_and_orphaned_calls():
927+
"""Test with both long-running and genuine orphaned calls.
928+
929+
Only the genuine orphaned call should receive synthetic error response.
930+
"""
931+
agent = Agent(model="gemini-2.5-flash", name="test_agent")
932+
llm_request = LlmRequest(model="gemini-2.5-flash")
933+
invocation_context = await testing_utils.create_invocation_context(
934+
agent=agent
935+
)
936+
937+
long_running_call = types.FunctionCall(
938+
id="long_running_call", name="request_approval", args={}
939+
)
940+
orphaned_call = types.FunctionCall(
941+
id="orphaned_call", name="quick_calc", args={}
942+
)
943+
944+
events = [
945+
Event(
946+
invocation_id="inv1",
947+
author="user",
948+
content=types.UserContent("Do multiple things"),
949+
),
950+
Event(
951+
invocation_id="inv2",
952+
author="test_agent",
953+
content=types.ModelContent([
954+
types.Part(function_call=long_running_call),
955+
types.Part(function_call=orphaned_call),
956+
]),
957+
long_running_tool_ids={"long_running_call"},
958+
),
959+
]
960+
invocation_context.session.events = events
961+
962+
async for _ in contents.request_processor.run_async(
963+
invocation_context, llm_request
964+
):
965+
pass
966+
967+
# Should have 3 contents: user message + function calls + synthetic response
968+
assert len(llm_request.contents) == 3
969+
970+
# Find the synthetic response
971+
response_content = llm_request.contents[2]
972+
response_ids = {
973+
part.function_response.id
974+
for part in response_content.parts
975+
if part.function_response
976+
}
977+
978+
# Only orphaned_call should have synthetic response
979+
assert "orphaned_call" in response_ids, (
980+
"Genuine orphaned call should be healed"
981+
)
982+
assert "long_running_call" not in response_ids, (
983+
"Long-running call should NOT be healed"
984+
)

0 commit comments

Comments
 (0)