Skip to content

Commit 0c517e7

Browse files
GWealecopybara-github
authored andcommitted
fix: recover compacted function calls during prompt assembly
Close #5602 Co-authored-by: George Weale <gweale@google.com> PiperOrigin-RevId: 944697651
1 parent 820a910 commit 0c517e7

2 files changed

Lines changed: 299 additions & 0 deletions

File tree

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

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -539,6 +539,96 @@ def _is_timestamp_compacted(ts: float) -> bool:
539539
return [event for _, _, event in processed_items]
540540

541541

542+
def _recover_compacted_function_calls(
543+
events: list[Event],
544+
source_events: list[Event],
545+
) -> list[Event]:
546+
"""Re-injects function-call events that compaction removed.
547+
548+
Compaction can summarize away a function_call while a matching
549+
function_response survives outside the compacted range. The clearest case
550+
is a long-running tool call: the call is compacted along with its
551+
intermediate placeholder response, then the real result arrives on resume
552+
(a later event not covered by the summary). That surviving response would
553+
be orphaned, which breaks call/response pairing during prompt assembly (it
554+
raises in `_rearrange_events_for_latest_function_response`).
555+
556+
For each response whose call is no longer present, this restores the
557+
original call event from `source_events` (the pre-compaction list),
558+
inserting it immediately before the first surviving response that
559+
references it. The whole call event is re-injected verbatim (rather than
560+
trimmed to the resumed call) so parallel-call thought signatures, which only
561+
the first part carries, are preserved. Any sibling responses that compaction
562+
removed are re-injected too, so a sibling is not surfaced as a phantom
563+
pending call.
564+
565+
Args:
566+
events: The post-compaction events being assembled into request contents.
567+
source_events: The pre-compaction events to recover missing calls from.
568+
569+
Returns:
570+
`events` with any recoverable missing function-call events (and their
571+
compacted sibling responses) re-injected; the original list is returned
572+
unchanged when nothing needs recovery.
573+
"""
574+
call_ids_present: set[str] = set()
575+
response_ids_present: set[str] = set()
576+
for event in events:
577+
for function_call in event.get_function_calls():
578+
if function_call.id:
579+
call_ids_present.add(function_call.id)
580+
for function_response in event.get_function_responses():
581+
if function_response.id:
582+
response_ids_present.add(function_response.id)
583+
584+
orphaned_ids = {
585+
response_id
586+
for response_id in response_ids_present
587+
if response_id not in call_ids_present
588+
}
589+
if not orphaned_ids:
590+
return events
591+
592+
call_event_by_id: dict[str, Event] = {}
593+
for event in source_events:
594+
for function_call in event.get_function_calls():
595+
if function_call.id in orphaned_ids:
596+
call_event_by_id.setdefault(function_call.id, event)
597+
598+
if not call_event_by_id:
599+
return events
600+
601+
response_event_by_id: dict[str, Event] = {}
602+
for event in source_events:
603+
for function_response in event.get_function_responses():
604+
if function_response.id:
605+
response_event_by_id.setdefault(function_response.id, event)
606+
607+
result: list[Event] = []
608+
reinjected_ids: set[str] = set()
609+
for event in events:
610+
for function_response in event.get_function_responses():
611+
call_event = call_event_by_id.get(function_response.id)
612+
if call_event is None or function_response.id in reinjected_ids:
613+
continue
614+
result.append(call_event)
615+
sibling_ids = [
616+
function_call.id
617+
for function_call in call_event.get_function_calls()
618+
if function_call.id
619+
]
620+
reinjected_ids.update(sibling_ids)
621+
# Recover sibling responses that compaction removed so a parallel sibling
622+
# is not left looking like a pending call.
623+
for sibling_id in sibling_ids:
624+
if sibling_id not in response_ids_present:
625+
sibling_response = response_event_by_id.get(sibling_id)
626+
if sibling_response is not None:
627+
result.append(sibling_response)
628+
result.append(event)
629+
return result
630+
631+
542632
def _copy_content_for_request(
543633
content: types.Content,
544634
*,
@@ -660,6 +750,12 @@ def _get_contents(
660750

661751
if has_compaction_events:
662752
events_to_process = _process_compaction_events(raw_filtered_events)
753+
# Compaction may have removed a function_call whose response survives
754+
# (e.g. a long-running call resumed after it was compacted); restore it so
755+
# the call/response pairing is intact.
756+
events_to_process = _recover_compacted_function_calls(
757+
events_to_process, raw_filtered_events
758+
)
663759
else:
664760
events_to_process = raw_filtered_events
665761

tests/unittests/flows/llm_flows/test_contents.py

Lines changed: 203 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@
1515
from google.adk.agents.llm_agent import Agent
1616
from google.adk.events.event import Event
1717
from google.adk.events.event_actions import EventActions
18+
from google.adk.events.event_actions import EventCompaction
1819
from google.adk.flows.llm_flows import _nl_planning
1920
from google.adk.flows.llm_flows import contents
2021
from google.adk.flows.llm_flows.contents import request_processor
@@ -1691,3 +1692,205 @@ def test_rearrange_async_function_responses_early_returns_when_no_responses():
16911692
events
16921693
)
16931694
assert result is events
1695+
1696+
1697+
def _long_running_call_event() -> Event:
1698+
return Event(
1699+
invocation_id="inv2",
1700+
author="model",
1701+
timestamp=2.0,
1702+
long_running_tool_ids={"lr-1"},
1703+
content=types.Content(
1704+
role="model",
1705+
parts=[
1706+
types.Part(
1707+
function_call=types.FunctionCall(
1708+
id="lr-1", name="lr_tool", args={}
1709+
)
1710+
)
1711+
],
1712+
),
1713+
)
1714+
1715+
1716+
def _long_running_response_event(response: dict[str, str]) -> Event:
1717+
return Event(
1718+
invocation_id="inv2",
1719+
author="user",
1720+
timestamp=4.0,
1721+
content=types.Content(
1722+
role="user",
1723+
parts=[
1724+
types.Part(
1725+
function_response=types.FunctionResponse(
1726+
id="lr-1", name="lr_tool", response=response
1727+
)
1728+
)
1729+
],
1730+
),
1731+
)
1732+
1733+
1734+
def test_recover_compacted_function_calls_reinjects_missing_call():
1735+
"""A response whose call was compacted gets its call re-injected before it."""
1736+
summary_event = Event(
1737+
invocation_id="compacted",
1738+
author="model",
1739+
timestamp=3.0,
1740+
content=types.Content(role="model", parts=[types.Part(text="summary")]),
1741+
)
1742+
call_event = _long_running_call_event()
1743+
resume_response = _long_running_response_event({"result": "done"})
1744+
1745+
# After compaction the call is gone from the effective list but survives in
1746+
# the source (pre-compaction) list.
1747+
effective = [summary_event, resume_response]
1748+
source = [call_event, resume_response]
1749+
1750+
result = contents._recover_compacted_function_calls(effective, source) # pylint: disable=protected-access
1751+
1752+
assert result == [summary_event, call_event, resume_response]
1753+
1754+
1755+
def test_recover_compacted_function_calls_noop_when_call_present():
1756+
"""No change when every response already has its call in the list."""
1757+
call_event = _long_running_call_event()
1758+
resume_response = _long_running_response_event({"result": "done"})
1759+
effective = [call_event, resume_response]
1760+
1761+
result = contents._recover_compacted_function_calls(effective, effective) # pylint: disable=protected-access
1762+
1763+
assert result is effective
1764+
1765+
1766+
def test_get_contents_recovers_compacted_long_running_call_on_resume():
1767+
"""A long-running call compacted before resume is restored during assembly.
1768+
1769+
Reproduces issue #5602: the call and its intermediate placeholder response are
1770+
summarized away, then the real result arrives on resume. Without recovery,
1771+
assembly raises because the resumed response has no matching call.
1772+
"""
1773+
compaction = EventCompaction(
1774+
start_timestamp=1.0,
1775+
end_timestamp=3.0,
1776+
compacted_content=types.Content(
1777+
role="model", parts=[types.Part(text="summary of earlier turns")]
1778+
),
1779+
)
1780+
events = [
1781+
Event(
1782+
invocation_id="inv1",
1783+
author="user",
1784+
timestamp=1.0,
1785+
content=types.UserContent("start the long job"),
1786+
),
1787+
_long_running_call_event(),
1788+
# Intermediate placeholder response (same invocation as the call).
1789+
_long_running_response_event({"status": "pending"}).model_copy(
1790+
update={"timestamp": 3.0}
1791+
),
1792+
Event(
1793+
invocation_id="compacted",
1794+
author="model",
1795+
timestamp=3.0,
1796+
content=compaction.compacted_content,
1797+
actions=EventActions(compaction=compaction),
1798+
),
1799+
# Real result delivered on resume; the runner stamps it with the call's
1800+
# invocation id, and its timestamp is outside the compacted range.
1801+
_long_running_response_event({"result": "done"}),
1802+
]
1803+
1804+
result = contents._get_contents(None, events, agent_name="model") # pylint: disable=protected-access
1805+
1806+
assert len(result) == 3
1807+
assert result[0].parts[0].text == "summary of earlier turns"
1808+
assert result[1].parts[0].function_call.id == "lr-1"
1809+
assert result[2].parts[0].function_response.id == "lr-1"
1810+
assert result[2].parts[0].function_response.response == {"result": "done"}
1811+
1812+
1813+
def test_recover_compacted_parallel_call_reinjects_sibling_response():
1814+
"""A recovered parallel call keeps every part and restores sibling responses.
1815+
1816+
The call event issues a long-running call (lr-1) and a regular call (reg-1)
1817+
together. Both, plus reg-1's response, are compacted; only lr-1 is resumed.
1818+
The whole call event is re-injected (so parallel-call thought signatures on
1819+
the first part survive), and reg-1's compacted response is restored so reg-1
1820+
is not surfaced as a phantom pending call.
1821+
"""
1822+
parallel_call = Event(
1823+
invocation_id="inv2",
1824+
author="model",
1825+
timestamp=2.0,
1826+
long_running_tool_ids={"lr-1"},
1827+
content=types.Content(
1828+
role="model",
1829+
parts=[
1830+
types.Part(
1831+
function_call=types.FunctionCall(
1832+
id="lr-1", name="lr_tool", args={}
1833+
)
1834+
),
1835+
types.Part(
1836+
function_call=types.FunctionCall(
1837+
id="reg-1", name="reg_tool", args={}
1838+
)
1839+
),
1840+
],
1841+
),
1842+
)
1843+
compaction = EventCompaction(
1844+
start_timestamp=1.0,
1845+
end_timestamp=3.5,
1846+
compacted_content=types.Content(
1847+
role="model", parts=[types.Part(text="summary")]
1848+
),
1849+
)
1850+
events = [
1851+
parallel_call,
1852+
# reg-1's response and lr-1's placeholder, both compacted.
1853+
_long_running_response_event({"status": "pending"}).model_copy(
1854+
update={"timestamp": 3.0}
1855+
),
1856+
Event(
1857+
invocation_id="inv2",
1858+
author="user",
1859+
timestamp=3.5,
1860+
content=types.Content(
1861+
role="user",
1862+
parts=[
1863+
types.Part(
1864+
function_response=types.FunctionResponse(
1865+
id="reg-1", name="reg_tool", response={"result": "ok"}
1866+
)
1867+
)
1868+
],
1869+
),
1870+
),
1871+
Event(
1872+
invocation_id="compacted",
1873+
author="model",
1874+
timestamp=3.5,
1875+
content=compaction.compacted_content,
1876+
actions=EventActions(compaction=compaction),
1877+
),
1878+
_long_running_response_event({"result": "done"}),
1879+
]
1880+
1881+
result = contents._get_contents(None, events, agent_name="model") # pylint: disable=protected-access
1882+
1883+
assert len(result) == 3
1884+
# The whole parallel call event is preserved (both parts, so a thought
1885+
# signature on the first part is not stripped).
1886+
call_ids = {
1887+
part.function_call.id for part in result[1].parts if part.function_call
1888+
}
1889+
assert call_ids == {"lr-1", "reg-1"}
1890+
# Both responses are present, so neither call looks pending.
1891+
response_ids = {
1892+
part.function_response.id
1893+
for part in result[2].parts
1894+
if part.function_response
1895+
}
1896+
assert response_ids == {"lr-1", "reg-1"}

0 commit comments

Comments
 (0)