|
12 | 12 | # See the License for the specific language governing permissions and |
13 | 13 | # limitations under the License. |
14 | 14 |
|
| 15 | +from __future__ import annotations |
| 16 | + |
| 17 | +import asyncio |
| 18 | +from collections.abc import Awaitable |
| 19 | +from collections.abc import Callable |
| 20 | +import copy |
| 21 | +from typing import Any |
| 22 | + |
15 | 23 | from google.adk.agents.llm_agent import Agent |
16 | 24 | from google.adk.events.event_actions import EventActions |
17 | 25 | from google.adk.tools.tool_context import ToolContext |
|
21 | 29 | from .... import testing_utils |
22 | 30 |
|
23 | 31 |
|
| 32 | +async def _run_parallel_calls( |
| 33 | + tool: Callable[..., Awaitable[None]], |
| 34 | + args_list: list[dict[str, str]], |
| 35 | + initial_state: dict[str, Any] | None = None, |
| 36 | +) -> dict[str, Any]: |
| 37 | + """Runs one model turn of parallel calls to `tool`, returns the stored state.""" |
| 38 | + function_calls = [ |
| 39 | + types.Part.from_function_call(name=tool.__name__, args=args) |
| 40 | + for args in args_list |
| 41 | + ] |
| 42 | + agent = Agent( |
| 43 | + name='root_agent', |
| 44 | + model=testing_utils.MockModel.create(responses=[function_calls, 'done']), |
| 45 | + tools=[tool], |
| 46 | + ) |
| 47 | + runner = testing_utils.TestInMemoryRunner(agent) |
| 48 | + session = await runner.session_service.create_session( |
| 49 | + app_name=runner.app_name, user_id='test_user', state=initial_state |
| 50 | + ) |
| 51 | + async for _ in runner.run_async( |
| 52 | + user_id='test_user', |
| 53 | + session_id=session.id, |
| 54 | + new_message=testing_utils.get_user_content('test'), |
| 55 | + ): |
| 56 | + pass |
| 57 | + stored = await runner.session_service.get_session( |
| 58 | + app_name=runner.app_name, user_id='test_user', session_id=session.id |
| 59 | + ) |
| 60 | + assert stored is not None |
| 61 | + return stored.state |
| 62 | + |
| 63 | + |
24 | 64 | @pytest.mark.asyncio |
25 | 65 | async def test_parallel_function_calls_with_state_change(): |
26 | 66 | function_calls = [ |
@@ -105,3 +145,89 @@ async def transfer_to_agent( |
105 | 145 | }, |
106 | 146 | transfer_to_agent='test_sub_agent', |
107 | 147 | ) |
| 148 | + |
| 149 | + |
| 150 | +@pytest.mark.asyncio |
| 151 | +async def test_parallel_appends_finishing_out_of_order_keep_every_item(): |
| 152 | + b_written = asyncio.Event() |
| 153 | + |
| 154 | + async def append_item(item: str, tool_context: ToolContext) -> None: |
| 155 | + if item == 'a': |
| 156 | + await asyncio.wait_for(b_written.wait(), timeout=5) |
| 157 | + tool_context.state['items'] = tool_context.state.get('items', []) + [item] |
| 158 | + b_written.set() |
| 159 | + |
| 160 | + state = await _run_parallel_calls(append_item, [{'item': 'a'}, {'item': 'b'}]) |
| 161 | + |
| 162 | + assert state['items'] == ['b', 'a'] |
| 163 | + |
| 164 | + |
| 165 | +@pytest.mark.asyncio |
| 166 | +async def test_parallel_appends_finishing_in_order_store_no_duplicates(): |
| 167 | + async def append_item(item: str, tool_context: ToolContext) -> None: |
| 168 | + tool_context.state['items'] = tool_context.state.get('items', []) + [item] |
| 169 | + |
| 170 | + state = await _run_parallel_calls(append_item, [{'item': 'a'}, {'item': 'b'}]) |
| 171 | + |
| 172 | + assert state['items'] == ['a', 'b'] |
| 173 | + |
| 174 | + |
| 175 | +@pytest.mark.asyncio |
| 176 | +async def test_parallel_snapshot_writes_keep_the_latest_nested_list(): |
| 177 | + b_written = asyncio.Event() |
| 178 | + |
| 179 | + async def add_label(label: str, tool_context: ToolContext) -> None: |
| 180 | + if label == 'a': |
| 181 | + await asyncio.wait_for(b_written.wait(), timeout=5) |
| 182 | + doc = copy.deepcopy(tool_context.state['doc']) |
| 183 | + doc['entities'][0]['labels'].append(label) |
| 184 | + tool_context.state['doc'] = doc |
| 185 | + b_written.set() |
| 186 | + |
| 187 | + state = await _run_parallel_calls( |
| 188 | + add_label, |
| 189 | + [{'label': 'a'}, {'label': 'b'}], |
| 190 | + initial_state={'doc': {'entities': [{'name': 'e1', 'labels': []}]}}, |
| 191 | + ) |
| 192 | + |
| 193 | + assert state['doc'] == {'entities': [{'name': 'e1', 'labels': ['b', 'a']}]} |
| 194 | + |
| 195 | + |
| 196 | +@pytest.mark.asyncio |
| 197 | +async def test_parallel_writes_of_separate_dict_keys_are_all_kept(): |
| 198 | + async def save_draft(draft_id: str, tool_context: ToolContext) -> None: |
| 199 | + tool_context.state['drafts'] = {draft_id: 'body'} |
| 200 | + |
| 201 | + state = await _run_parallel_calls( |
| 202 | + save_draft, [{'draft_id': 'a'}, {'draft_id': 'b'}] |
| 203 | + ) |
| 204 | + |
| 205 | + assert state['drafts'] == {'a': 'body', 'b': 'body'} |
| 206 | + |
| 207 | + |
| 208 | +@pytest.mark.asyncio |
| 209 | +async def test_parallel_list_writes_bypassing_state_keep_call_order(): |
| 210 | + async def set_items(item: str, tool_context: ToolContext) -> None: |
| 211 | + tool_context.actions.state_delta['items'] = [item] |
| 212 | + |
| 213 | + state = await _run_parallel_calls( |
| 214 | + set_items, |
| 215 | + [{'item': 'a'}, {'item': 'b'}], |
| 216 | + initial_state={'items': ['old']}, |
| 217 | + ) |
| 218 | + |
| 219 | + assert state['items'] == ['b'] |
| 220 | + |
| 221 | + |
| 222 | +@pytest.mark.asyncio |
| 223 | +async def test_parallel_scalar_writes_bypassing_state_keep_call_order(): |
| 224 | + async def set_done(done: str, tool_context: ToolContext) -> None: |
| 225 | + tool_context.actions.state_delta['done'] = done == 'yes' |
| 226 | + |
| 227 | + state = await _run_parallel_calls( |
| 228 | + set_done, |
| 229 | + [{'done': 'no'}, {'done': 'yes'}], |
| 230 | + initial_state={'done': False}, |
| 231 | + ) |
| 232 | + |
| 233 | + assert state['done'] is True |
0 commit comments