Skip to content

Commit 0ee78fd

Browse files
fix(sdk): tighten async generator chunk normalization
1 parent ca17433 commit 0ee78fd

2 files changed

Lines changed: 65 additions & 22 deletions

File tree

‎sdks/python/src/agent_control/control_decorators.py‎

Lines changed: 20 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -225,13 +225,12 @@ def output_payload(self) -> JSONValue:
225225
if not self.normalized_chunks:
226226
return ""
227227

228-
if all(isinstance(chunk, str) for chunk in self.normalized_chunks):
229-
return "".join(chunk for chunk in self.normalized_chunks if isinstance(chunk, str))
228+
text_chunks = [chunk for chunk in self.normalized_chunks if isinstance(chunk, str)]
229+
if len(text_chunks) == len(self.normalized_chunks):
230+
return "".join(text_chunks)
230231

231232
output: dict[str, JSONValue] = {"chunks": list(self.normalized_chunks)}
232-
text_output = "".join(
233-
chunk for chunk in self.normalized_chunks if isinstance(chunk, str)
234-
)
233+
text_output = "".join(text_chunks)
235234
if text_output:
236235
output["text"] = text_output
237236
return output
@@ -276,11 +275,19 @@ def _normalize_json_value(value: Any, *, _seen: set[int] | None = None) -> JSONV
276275

277276
model_dump = getattr(value, "model_dump", None)
278277
if callable(model_dump):
279-
return _normalize_json_value(model_dump(mode="json"), _seen=seen)
278+
seen.add(value_id)
279+
try:
280+
return _normalize_json_value(model_dump(mode="json"), _seen=seen)
281+
finally:
282+
seen.remove(value_id)
280283

281284
dict_method = getattr(value, "dict", None)
282285
if callable(dict_method):
283-
return _normalize_json_value(dict_method(), _seen=seen)
286+
seen.add(value_id)
287+
try:
288+
return _normalize_json_value(dict_method(), _seen=seen)
289+
finally:
290+
seen.remove(value_id)
284291

285292
if isinstance(value, Mapping):
286293
seen.add(value_id)
@@ -369,14 +376,6 @@ def _build_control_context(
369376
)
370377

371378

372-
async def _close_async_generator(stream: AsyncGenerator[Any, None]) -> None:
373-
"""Close a buffered async generator without masking the original failure."""
374-
try:
375-
await stream.aclose()
376-
except Exception:
377-
logger.debug("Failed to close buffered async generator cleanly", exc_info=True)
378-
379-
380379
async def _evaluate(
381380
agent_name: str,
382381
step: dict[str, Any],
@@ -946,7 +945,10 @@ def decorator(func: F) -> F:
946945
register(func, policy)
947946

948947
@functools.wraps(func)
949-
async def async_gen_wrapper(*args: Any, **kwargs: Any) -> Any:
948+
async def async_gen_wrapper(
949+
*args: Any,
950+
**kwargs: Any,
951+
) -> AsyncGenerator[Any, None]:
950952
context = _build_control_context(func, args, kwargs, step_name)
951953
if context is None:
952954
logger.warning(
@@ -965,12 +967,8 @@ async def async_gen_wrapper(*args: Any, **kwargs: Any) -> Any:
965967
try:
966968
await _run_control_check(ctx, "pre", ctx.pre_payload(), controls)
967969

968-
try:
969-
async for chunk in stream:
970-
capture.add(chunk)
971-
except BaseException:
972-
await _close_async_generator(stream)
973-
raise
970+
async for chunk in stream:
971+
capture.add(chunk)
974972

975973
await _run_control_check(
976974
ctx,

‎sdks/python/tests/test_control_decorators.py‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -800,6 +800,15 @@ class _StructuredChunk:
800800
value: str
801801

802802

803+
class _RecursiveModelDumpChunk:
804+
def model_dump(self, mode: str = "json") -> dict[str, object]:
805+
_ = mode
806+
return {"self": self}
807+
808+
def __str__(self) -> str:
809+
return "recursive-model-dump"
810+
811+
803812
class TestAsyncGeneratorControl:
804813
"""Tests for buffered async-generator support in @control()."""
805814

@@ -928,6 +937,42 @@ async def stream(message: str):
928937
"text": "tail",
929938
}
930939

940+
@pytest.mark.asyncio
941+
async def test_model_dump_cycle_is_guarded_during_chunk_normalization(
942+
self,
943+
mock_agent,
944+
mock_safe_response,
945+
):
946+
"""Test that self-referential model_dump payloads do not recurse forever."""
947+
post_steps = []
948+
949+
async def mock_evaluate(
950+
agent_name,
951+
step,
952+
stage,
953+
server_url,
954+
trace_id=None,
955+
span_id=None,
956+
controls=None,
957+
event_agent_name=None,
958+
):
959+
if stage == "post":
960+
post_steps.append(step)
961+
return mock_safe_response
962+
963+
with patch("agent_control.control_decorators._get_current_agent", return_value=mock_agent), \
964+
patch("agent_control.control_decorators._evaluate", side_effect=mock_evaluate):
965+
966+
@control()
967+
async def stream(message: str):
968+
yield _RecursiveModelDumpChunk()
969+
970+
chunks = [chunk async for chunk in stream("test")]
971+
assert len(chunks) == 1
972+
assert post_steps[0]["output"] == {
973+
"chunks": [{"self": "recursive-model-dump"}]
974+
}
975+
931976
@pytest.mark.asyncio
932977
async def test_post_check_block_happens_before_any_chunk_is_replayed(
933978
self,

0 commit comments

Comments
 (0)