|
15 | 15 | from google.adk.agents.llm_agent import Agent |
16 | 16 | from google.adk.events.event import Event |
17 | 17 | from google.adk.events.event_actions import EventActions |
| 18 | +from google.adk.events.event_actions import EventCompaction |
18 | 19 | from google.adk.flows.llm_flows import _nl_planning |
19 | 20 | from google.adk.flows.llm_flows import contents |
20 | 21 | 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(): |
1691 | 1692 | events |
1692 | 1693 | ) |
1693 | 1694 | 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