Skip to content

Commit aa9c187

Browse files
surajit-1306GWeale
authored andcommitted
fix(cli): stream_reasoning_engine raises StopIteration RuntimeError on sync generators
Merge #6114 ## Link to Issue or Description of Change Closes : #6093 **Problem:** On Agent Engine deployments served by the ADK API server, every call to the `/api/stream_reasoning_engine` route with a synchronous streaming `class_method` (e.g. `stream_query`) ends with `RuntimeError: coroutine raised StopIteration` after the last chunk is streamed. The cause is the sync-to-async adapter `_aiter_from_iter` in `src/google/adk/cli/fast_api.py` (lines 916–922 in v2.2.0): async def _aiter_from_iter(iterator): while True: try: chunk = await run_in_threadpool(next, iterator) yield chunk except StopIteration: break The `except StopIteration` is unreachable. When the iterator is exhausted, `next()` raises `StopIteration` inside the worker thread, anyio sets it on a future, and it propagates out of the `run_in_threadpool` coroutine frame. Python (PEP 479) forbids `StopIteration` escaping a coroutine and converts it to `RuntimeError("coroutine raised StopIteration")` before the `except` clause ever sees it. **Affected versions:** Regression introduced in v2.2.0 — the route and the buggy adapter were added in the same commit. Not present in the v1.x line (verified absent at v1.35.0). **Solution:** Stop relying on `StopIteration` crossing the await boundary; use a sentinel default so iterator exhaustion never raises across it: _SENTINEL = object() async def _aiter_from_iter(iterator): while True: chunk = await run_in_threadpool(next, iterator, _SENTINEL) if chunk is _SENTINEL: break yield chunk This is the minimal, idiomatic fix; the stream now terminates cleanly when the sync generator is exhausted. ## Testing Plan **Unit Tests:** - [x] I have added or updated unit tests for my change. - [x] All unit tests pass locally. Added `test_gemini_stream_reasoning_engine_sync_generator` plus a `test_app_with_gemini_enterprise_sync_stream` fixture in `tests/unittests/cli/test_fast_api.py`. The pre-existing stream test used an *async* generator (the `isasyncgenfunction` branch) and never exercised the buggy sync-generator path. The new test fails on the unpatched code with `RuntimeError` and passes with the fix. pytest summary: $ pytest tests/unittests/cli/test_fast_api.py -k stream_reasoning_engine -q 3 passed, 79 deselected $ pytest tests/unittests/cli/test_fast_api.py -q 82 passed **Manual End-to-End (E2E) Tests:** The failure and the fix reproduce standalone in ~15 lines, independent of any model or deployment: import asyncio from starlette.concurrency import run_in_threadpool async def _aiter_from_iter(iterator): # old, buggy version while True: try: chunk = await run_in_threadpool(next, iterator) yield chunk except StopIteration: break async def main(): def gen(): yield 1 yield 2 async for c in _aiter_from_iter(gen()): print("chunk:", c) asyncio.run(main()) # chunk: 1 # chunk: 2 # RuntimeError: coroutine raised StopIteration <-- before the fix With the sentinel version above, the same script prints the two chunks and exits cleanly with no exception. Originally observed on a live Vertex AI Agent Engine deployment (google-adk==2.2.0, Python 3.11) where every `stream_query` call logged the RuntimeError after the final chunk. ## Checklist - [x] I have read the CONTRIBUTING.md document. - [x] I have performed a self-review of my own code. - [x] I have commented my code, particularly in hard-to-understand areas. - [x] I have added tests that prove my fix is effective or that my feature works. - [x] New and existing unit tests pass locally with my changes. - [x] I have manually tested my changes end-to-end. - [ ] Any dependent changes have been merged and published in downstream modules. ## Additional context Original server traceback: ERROR: Exception in ASGI application Traceback (most recent call last): File ".../starlette/responses.py", line 250, in stream_response async for chunk in self.body_iterator: File ".../google/adk/cli/fast_api.py", line 797, in json_generator async for chunk in output: File ".../google/adk/cli/fast_api.py", line 919, in _aiter_from_iter chunk = await run_in_threadpool(next, iterator) File ".../starlette/concurrency.py", line 32, in run_in_threadpool return await anyio.to_thread.run_sync(func) File ".../anyio/to_thread.py", line 63, in run_sync return await get_async_backend().run_sync_in_worker_thread( File ".../anyio/_backends/_asyncio.py", line 2518, in run_sync_in_worker_thread return await future RuntimeError: coroutine raised StopIteration Occurs 100% of the time on every sync streaming request once the generator is exhausted. The bug is model-agnostic (purely in the FastAPI streaming adapter). Co-authored-by: George Weale <gweale@google.com> COPYBARA_INTEGRATE_REVIEW=#6114 from surajit-1306:fix/stream-reasoning-engine-stopiteration e5ee866 PiperOrigin-RevId: 962875380
1 parent f4fd7d5 commit aa9c187

2 files changed

Lines changed: 89 additions & 4 deletions

File tree

src/google/adk/cli/fast_api.py

Lines changed: 11 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -604,14 +604,21 @@ async def stream_query(request: Request):
604604
output = await _invoke_callable_or_raise(method, parsed.input or {})
605605

606606
if inspect.isgenerator(output):
607+
# Sentinel-based exhaustion check. We cannot rely on catching
608+
# StopIteration here: when ``next(iterator)`` is called inside the
609+
# threadpool worker, the StopIteration propagates out of the
610+
# ``run_in_threadpool`` coroutine frame, and Python (PEP 479) converts
611+
# it to ``RuntimeError("coroutine raised StopIteration")`` before the
612+
# ``except StopIteration`` clause can ever see it. Passing a default to
613+
# ``next`` avoids raising at the boundary entirely.
614+
_SENTINEL = object()
607615

608616
async def _aiter_from_iter(iterator):
609617
while True:
610-
try:
611-
chunk = await run_in_threadpool(next, iterator)
612-
yield chunk
613-
except StopIteration:
618+
chunk = await run_in_threadpool(next, iterator, _SENTINEL)
619+
if chunk is _SENTINEL:
614620
break
621+
yield chunk
615622

616623
content_iter = _aiter_from_iter(output)
617624
else:

tests/unittests/cli/test_fast_api.py

100755100644
Lines changed: 78 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1046,6 +1046,63 @@ async def stream_query_impl(**kwargs):
10461046
yield client
10471047

10481048

1049+
@pytest.fixture
1050+
def test_app_with_gemini_enterprise_sync_stream(
1051+
mock_session_service,
1052+
mock_artifact_service,
1053+
mock_memory_service,
1054+
mock_agent_loader,
1055+
mock_eval_sets_manager,
1056+
mock_eval_set_results_manager,
1057+
monkeypatch,
1058+
):
1059+
"""Like test_app_with_gemini_enterprise but stream_query is a sync generator.
1060+
1061+
This exercises the inspect.isgenerator() branch in stream_reasoning_engine,
1062+
where the sync iterator is adapted to an async iterator via a threadpool.
1063+
"""
1064+
monkeypatch.setenv("GOOGLE_CLOUD_PROJECT", "test-project")
1065+
mock_agent_loader.list_agents = MagicMock(
1066+
return_value=["test_app", "gemini_app"]
1067+
)
1068+
1069+
mock_adk_app_instance = MagicMock()
1070+
mock_adk_app_instance._tmpl_attrs = {}
1071+
1072+
def stream_query_impl(**kwargs):
1073+
yield {"chunk": 1, "kwargs": kwargs}
1074+
yield {"chunk": 2, "kwargs": kwargs}
1075+
1076+
mock_adk_app_instance.stream_query = stream_query_impl
1077+
1078+
with (
1079+
patch("google.auth.default", return_value=(MagicMock(), "test-project")),
1080+
patch("vertexai.init", new_callable=MagicMock),
1081+
patch(
1082+
"vertexai.agent_engines.AdkApp", return_value=mock_adk_app_instance
1083+
),
1084+
patch("google.adk.agents.Agent", new_callable=MagicMock),
1085+
patch(
1086+
"google.adk.telemetry._agent_engine.TopSpanProcessor",
1087+
new_callable=MagicMock,
1088+
),
1089+
patch(
1090+
"google.adk.telemetry._agent_engine.get_propagated_context",
1091+
new_callable=MagicMock,
1092+
),
1093+
):
1094+
client = _create_test_client(
1095+
mock_session_service,
1096+
mock_artifact_service,
1097+
mock_memory_service,
1098+
mock_agent_loader,
1099+
mock_eval_sets_manager,
1100+
mock_eval_set_results_manager,
1101+
gemini_enterprise_app_name="gemini_app",
1102+
)
1103+
yield client
1104+
1105+
10491106
#################################################
10501107
# Test Cases
10511108
#################################################
@@ -3581,6 +3638,27 @@ def test_gemini_stream_reasoning_engine_missing_class_method(
35813638
assert response.status_code == 400
35823639

35833640

3641+
def test_gemini_stream_reasoning_engine_sync_generator(
3642+
test_app_with_gemini_enterprise_sync_stream,
3643+
):
3644+
"""Regression test: a synchronous streaming class_method must not raise.
3645+
3646+
A sync generator is adapted to an async iterator via run_in_threadpool. The
3647+
adapter must not rely on catching StopIteration across the await boundary,
3648+
since Python (PEP 479) converts an escaping StopIteration into
3649+
RuntimeError("coroutine raised StopIteration") after the final chunk.
3650+
"""
3651+
response = test_app_with_gemini_enterprise_sync_stream.post(
3652+
"/api/stream_reasoning_engine",
3653+
json={"class_method": "stream_query", "input": {"arg1": 1}},
3654+
)
3655+
assert response.status_code == 200
3656+
lines = response.text.strip().split("\n")
3657+
assert len(lines) == 2
3658+
assert json.loads(lines[0]) == {"chunk": 1, "kwargs": {"arg1": 1}}
3659+
assert json.loads(lines[1]) == {"chunk": 2, "kwargs": {"arg1": 1}}
3660+
3661+
35843662
def test_run_eval_request_live_fields_default():
35853663
"""RunEvalRequest defaults to non-live mode."""
35863664
from google.adk.cli.dev_server import RunEvalRequest

0 commit comments

Comments
 (0)