Hey!
I need help about where can I get help basically.
TL;DR: Agent Engine calls Runner.run instead of Runner.run_async and that potentially causes python concurrency issues via genai library.
My agent code is pretty basic, small deterministic custom agent done strictly by ADK docs calls classifier which is llmagent without tools and returns 0 or 1, if 1 - rag agent is called to generate answer.
exception.message: Task <Task pending name='Task-72' coro=<Runner.run.<locals>._invoke_run_async()
running at /code/.venv/lib/python3.12/site-packages/google/adk/runners.py:479>
cb=[_run_until_complete_cb() at /usr/local/lib/python3.12/asyncio/base_events.py:181]>
got Future <Future pending cb=[_chain_future.<locals>._call_check_cancel()
at /usr/local/lib/python3.12/asyncio/futures.py:389]> attached to a different loop
Long version:
Why Runner.run() crashes under concurrency
The problem
Runner.run() is a sync wrapper around run_async(). It spawns a new thread and calls asyncio.run() inside it:
# google-adk runners.py:484-491
def _asyncio_thread_main():
asyncio.run(_invoke_run_async()) # new event loop every time
thread = create_thread(target=_asyncio_thread_main)
thread.start()
asyncio.run() always creates a fresh event loop. When N requests come in concurrently, that means N threads, each running its own event loop.
The problem is that all of these threads share a single genai.Client, because ADK’s Gemini model creates it once via @cached_property:
# google-adk google_llm.py:298
@cached_property
def api_client(self) -> Client:
return Client(...)
So you end up with one Gemini instance, one genai.Client, and one _aiohttp_session slot, shared across all threads and all event loops.
How the crash happens
When request-1 (running on loop-1) first calls the LLM, _get_aiohttp_session() creates a new aiohttp.ClientSession. That session internally stores the event loop it was created on (self._loop = loop-1).
When request-2 (running on loop-2) arrives while request-1 is still in progress, it also calls _get_aiohttp_session(). The method checks three conditions:
# google-genai _api_client.py:750-752
if (
self._aiohttp_session is None # False, already created
or self._aiohttp_session.closed # False, request-1 still using it
or self._aiohttp_session._loop.is_closed() # False, loop-1 still running
):
All three are False, so it returns the existing session (bound to loop-1) to code running on loop-2.
When aiohttp then tries to use the session, it calls self._loop.run_in_executor(...) where self._loop is loop-1, but the current execution context is loop-2. asyncio detects this and raises:
RuntimeError: Task <...> got Future <...> attached to a different loop
The fix would be a fourth check:
or self._aiohttp_session._loop is not asyncio.get_running_loop()
This would detect that the session belongs to a different (but still open) loop and create a new one for the current loop.
Why run_async() does not have this problem
run_async() is a regular async def. It runs on whatever event loop the caller provides. In an async server (which Agent Engine is), all concurrent requests are coroutines on the same event loop. One loop means one aiohttp session, and self._loop always matches the running loop. There is no mismatch and no crash.
ADK’s own docstring on Runner.run() says as much:
NOTE: This sync interface is only for local testing and convenience purpose. Consider usingrun_asyncfor production usage.
Why it is timing-dependent
If a request arrives after a previous asyncio.run() has already completed and closed its loop, check 3 (._loop.is_closed()) catches it and creates a fresh session. The bug only triggers when two requests overlap in time, so the previous loop is still open. In our testing, 40-60% of concurrent requests fail depending on timing.
Object ownership
Gemini (singleton, shared by all requests)
api_client (one genai.Client, cached)
_api_client._aiohttp_session (one slot, shared across threads)
_loop = the event loop that created the session
Runner.run() call 1: Thread-1, asyncio.run(), loop-1
Runner.run() call 2: Thread-2, asyncio.run(), loop-2 all share the same session
Runner.run() call 3: Thread-3, asyncio.run(), loop-3