@@ -31,11 +31,11 @@ async def chat(message: str) -> str:
3131import functools
3232import inspect
3333import 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
3636from typing import Any , TypeVar
3737
38- from agent_control_models import Step
38+ from agent_control_models import JSONValue , Step
3939
4040from agent_control import AgentControlClient
4141from 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+
213240def _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+
227380async 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