Skip to content

Commit ca17433

Browse files
fix(sdk): buffer async generator outputs in control decorator
1 parent b76014f commit ca17433

2 files changed

Lines changed: 442 additions & 41 deletions

File tree

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

Lines changed: 209 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -31,11 +31,11 @@ async def chat(message: str) -> str:
3131
import functools
3232
import inspect
3333
import time
34-
from collections.abc import Callable
35-
from dataclasses import dataclass, field
34+
from collections.abc import AsyncGenerator, Callable, Mapping
35+
from dataclasses import dataclass, field, fields, is_dataclass
3636
from typing import Any, TypeVar
3737

38-
from agent_control_models import Step
38+
from agent_control_models import JSONValue, Step
3939

4040
from agent_control import AgentControlClient
4141
from agent_control.evaluation import check_evaluation_with_local
@@ -210,6 +210,33 @@ def __init__(
210210
)
211211

212212

213+
@dataclass
214+
class _BufferedStreamCapture:
215+
"""Buffer streamed chunks for post-check evaluation before replay."""
216+
217+
replay_chunks: list[Any] = field(default_factory=list)
218+
normalized_chunks: list[JSONValue] = field(default_factory=list)
219+
220+
def add(self, chunk: Any) -> None:
221+
self.replay_chunks.append(chunk)
222+
self.normalized_chunks.append(_normalize_json_value(chunk))
223+
224+
def output_payload(self) -> JSONValue:
225+
if not self.normalized_chunks:
226+
return ""
227+
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))
230+
231+
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+
)
235+
if text_output:
236+
output["text"] = text_output
237+
return output
238+
239+
213240
def _get_current_agent() -> Any | None:
214241
"""Get the current agent from agent_control module."""
215242
try:
@@ -224,6 +251,132 @@ def _get_server_url() -> str:
224251
return get_settings().url
225252

226253

254+
def _copy_public_attributes(source: Any, target: Any) -> None:
255+
"""Copy public function attributes onto a wrapped callable."""
256+
for attr in dir(source):
257+
if not attr.startswith("_") and attr not in ("__call__", "__wrapped__"):
258+
try:
259+
setattr(target, attr, getattr(source, attr))
260+
except (AttributeError, TypeError):
261+
pass
262+
263+
264+
def _normalize_json_value(value: Any, *, _seen: set[int] | None = None) -> JSONValue:
265+
"""Convert runtime values into JSON-safe data for evaluation payloads."""
266+
if isinstance(value, (str, int, float, bool)) or value is None:
267+
return value
268+
269+
if isinstance(value, (bytes, bytearray)):
270+
return bytes(value).decode("utf-8", errors="replace")
271+
272+
seen = set() if _seen is None else _seen
273+
value_id = id(value)
274+
if value_id in seen:
275+
return str(value)
276+
277+
model_dump = getattr(value, "model_dump", None)
278+
if callable(model_dump):
279+
return _normalize_json_value(model_dump(mode="json"), _seen=seen)
280+
281+
dict_method = getattr(value, "dict", None)
282+
if callable(dict_method):
283+
return _normalize_json_value(dict_method(), _seen=seen)
284+
285+
if isinstance(value, Mapping):
286+
seen.add(value_id)
287+
try:
288+
return {
289+
str(key): _normalize_json_value(item, _seen=seen)
290+
for key, item in value.items()
291+
}
292+
finally:
293+
seen.remove(value_id)
294+
295+
if isinstance(value, (list, tuple, set, frozenset)):
296+
seen.add(value_id)
297+
try:
298+
return [_normalize_json_value(item, _seen=seen) for item in value]
299+
finally:
300+
seen.remove(value_id)
301+
302+
if is_dataclass(value) and not isinstance(value, type):
303+
seen.add(value_id)
304+
try:
305+
return {
306+
field_info.name: _normalize_json_value(
307+
getattr(value, field_info.name),
308+
_seen=seen,
309+
)
310+
for field_info in fields(value)
311+
}
312+
finally:
313+
seen.remove(value_id)
314+
315+
value_dict = getattr(value, "__dict__", None)
316+
if isinstance(value_dict, dict):
317+
seen.add(value_id)
318+
try:
319+
return {
320+
str(key): _normalize_json_value(item, _seen=seen)
321+
for key, item in value_dict.items()
322+
}
323+
finally:
324+
seen.remove(value_id)
325+
326+
return str(value)
327+
328+
329+
def _normalized_output_value(output: Any) -> JSONValue | None:
330+
"""Return normalized output for a post-check payload."""
331+
if output is None:
332+
return None
333+
return _normalize_json_value(output)
334+
335+
336+
def _build_control_context(
337+
func: Callable,
338+
args: tuple,
339+
kwargs: dict,
340+
step_name: str | None,
341+
) -> tuple[ControlContext, list[dict[str, Any]] | None] | None:
342+
"""Create the shared control context for a protected invocation."""
343+
agent = _get_current_agent()
344+
if agent is None:
345+
return None
346+
347+
controls = _get_server_controls()
348+
349+
existing_trace_id = get_current_trace_id()
350+
if existing_trace_id:
351+
trace_id = existing_trace_id
352+
span_id = _generate_span_id()
353+
else:
354+
trace_id, span_id = get_trace_and_span_ids()
355+
356+
return (
357+
ControlContext(
358+
agent_name=agent.agent_name,
359+
server_url=_get_server_url(),
360+
func=func,
361+
args=args,
362+
kwargs=kwargs,
363+
trace_id=trace_id,
364+
span_id=span_id,
365+
start_time=time.perf_counter(),
366+
step_name=step_name,
367+
),
368+
controls,
369+
)
370+
371+
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+
227380
async def _evaluate(
228381
agent_name: str,
229382
step: dict[str, Any],
@@ -480,9 +633,7 @@ def _create_evaluation_payload(
480633
"type": "tool",
481634
"name": determined_name,
482635
"input": dict(bound.arguments),
483-
"output": output if isinstance(output, (str, int, float, bool, dict, list)) else (
484-
None if output is None else str(output)
485-
),
636+
"output": _normalized_output_value(output),
486637
}
487638

488639
# This is an LLM step
@@ -491,9 +642,7 @@ def _create_evaluation_payload(
491642
"type": "llm",
492643
"name": determined_name,
493644
"input": input_data,
494-
"output": output if isinstance(output, (str, int, float, bool, dict, list)) else (
495-
None if output is None else str(output)
496-
),
645+
"output": _normalized_output_value(output),
497646
}
498647

499648

@@ -687,8 +836,8 @@ async def _execute_with_control(
687836
ControlSteerError: If any control triggers with "steer" action
688837
RuntimeError: If control evaluation fails unexpectedly
689838
"""
690-
agent = _get_current_agent()
691-
if agent is None:
839+
context = _build_control_context(func, args, kwargs, step_name)
840+
if context is None:
692841
logger.warning(
693842
"No agent initialized. Call agent_control.init() first. "
694843
"Running without protection."
@@ -697,29 +846,7 @@ async def _execute_with_control(
697846
return await func(*args, **kwargs)
698847
return func(*args, **kwargs)
699848

700-
# Get cached controls for local evaluation support
701-
controls = _get_server_controls()
702-
703-
# Get trace context: inherit trace_id if set, always generate new span_id
704-
# This allows multiple @control() calls to share the same trace but have unique spans
705-
existing_trace_id = get_current_trace_id()
706-
if existing_trace_id:
707-
trace_id = existing_trace_id
708-
span_id = _generate_span_id() # New span for this function
709-
else:
710-
trace_id, span_id = get_trace_and_span_ids() # New trace and span
711-
712-
ctx = ControlContext(
713-
agent_name=agent.agent_name,
714-
server_url=_get_server_url(),
715-
func=func,
716-
args=args,
717-
kwargs=kwargs,
718-
trace_id=trace_id,
719-
span_id=span_id,
720-
start_time=time.perf_counter(),
721-
step_name=step_name,
722-
)
849+
ctx, controls = context
723850
ctx.log_start()
724851

725852
try:
@@ -768,6 +895,11 @@ def control(policy: str | None = None, step_name: str | None = None) -> Callable
768895
3. After function execution: Calls server with stage="post"
769896
- Server evaluates all matching "post" controls for the agent
770897
898+
Async generator note:
899+
- Decorated async generators are buffered before replay.
900+
- The post-check runs on the full buffered output.
901+
- Chunks are yielded to the caller only after the post-check passes.
902+
771903
Example:
772904
import agent_control
773905
@@ -813,26 +945,62 @@ def decorator(func: F) -> F:
813945

814946
register(func, policy)
815947

948+
@functools.wraps(func)
949+
async def async_gen_wrapper(*args: Any, **kwargs: Any) -> Any:
950+
context = _build_control_context(func, args, kwargs, step_name)
951+
if context is None:
952+
logger.warning(
953+
"No agent initialized. Call agent_control.init() first. "
954+
"Running without protection."
955+
)
956+
async for chunk in func(*args, **kwargs):
957+
yield chunk
958+
return
959+
960+
ctx, controls = context
961+
ctx.log_start()
962+
stream = func(*args, **kwargs)
963+
capture = _BufferedStreamCapture()
964+
965+
try:
966+
await _run_control_check(ctx, "pre", ctx.pre_payload(), controls)
967+
968+
try:
969+
async for chunk in stream:
970+
capture.add(chunk)
971+
except BaseException:
972+
await _close_async_generator(stream)
973+
raise
974+
975+
await _run_control_check(
976+
ctx,
977+
"post",
978+
ctx.post_payload(capture.output_payload()),
979+
controls,
980+
)
981+
982+
for chunk in capture.replay_chunks:
983+
yield chunk
984+
finally:
985+
ctx.log_end()
986+
816987
@functools.wraps(func)
817988
async def async_wrapper(*args: Any, **kwargs: Any) -> Any:
818989
return await _execute_with_control(
819990
func, args, kwargs, is_async=True, step_name=step_name
820991
)
821992

822-
# Copy over ALL attributes from the original function (important for LangChain tools)
823-
for attr in dir(func):
824-
if not attr.startswith('_') and attr not in ('__call__', '__wrapped__'):
825-
try:
826-
setattr(async_wrapper, attr, getattr(func, attr))
827-
except (AttributeError, TypeError):
828-
pass
993+
_copy_public_attributes(func, async_gen_wrapper)
994+
_copy_public_attributes(func, async_wrapper)
829995

830996
@functools.wraps(func)
831997
def sync_wrapper(*args: Any, **kwargs: Any) -> Any:
832998
return asyncio.run(
833999
_execute_with_control(func, args, kwargs, is_async=False, step_name=step_name)
8341000
)
8351001

1002+
if inspect.isasyncgenfunction(func):
1003+
return async_gen_wrapper # type: ignore
8361004
if inspect.iscoroutinefunction(func):
8371005
return async_wrapper # type: ignore
8381006
return sync_wrapper # type: ignore

0 commit comments

Comments
 (0)