88import re
99import statistics
1010import time
11+ from dataclasses import replace
1112from typing import Any , Callable , Dict , List , Literal , Optional , Union
1213
1314import pytest
@@ -269,6 +270,82 @@ def generate_combinations():
269270
270271 return combinations
271272
273+ async def rollout_processor_with_retry (
274+ rollout_processor : RolloutProcessor ,
275+ fresh_dataset : List [EvaluationRow ],
276+ config : RolloutProcessorConfig ,
277+ max_retry : int ,
278+ ):
279+ """
280+ Wrapper around rollout_processor that handles retry logic internally.
281+ Uses async queue pattern to yield results immediately as they become available.
282+ Yields both successful and failed results, leaving it up to the user to handle them in test_func.
283+ """
284+
285+ try :
286+ queue = asyncio .Queue ()
287+ retry_counts = {r .execution_metadata .rollout_id : 0 for r in fresh_dataset }
288+ failed_permanently = []
289+
290+ async def retry_handler (failed_row : EvaluationRow ):
291+ rollout_id = failed_row .execution_metadata .rollout_id
292+ current_attempts = retry_counts .get (rollout_id , 0 )
293+
294+ if current_attempts >= max_retry :
295+ assert (
296+ failed_row .rollout_status and failed_row .rollout_status .status == "error"
297+ ), f"Rollout { failed_row .execution_metadata .rollout_id } did not fail with error status"
298+ failed_permanently .append (failed_row )
299+ await queue .put (failed_row ) # put failed row on queue
300+ return
301+
302+ retry_counts [rollout_id ] = current_attempts + 1
303+
304+ # add kwargs start_server=False to config so we don't start new MCP server
305+ retry_config = replace (config , kwargs = {** (config .kwargs or {}), "start_server" : False })
306+
307+ retry_call = rollout_processor ([failed_row ], retry_config )
308+
309+ retry_result = await anext (retry_call )
310+ if retry_result .rollout_status and retry_result .rollout_status .status == "finished" :
311+ await queue .put (retry_result )
312+ else :
313+ asyncio .create_task (retry_handler (retry_result )) # retry failed, spawn another retry
314+
315+ async def initial_processor ():
316+ """Process initial batch and spawn retries for failures"""
317+ async for initial_row in rollout_processor (fresh_dataset , config ):
318+ if initial_row .rollout_status and initial_row .rollout_status .status == "finished" :
319+ await queue .put (initial_row ) # rollout succeeded, put on queue
320+ else :
321+ asyncio .create_task (retry_handler (initial_row )) # rollout errored, spawn retry task
322+
323+ processor_task = asyncio .create_task (initial_processor ())
324+
325+ # yield results as they become available
326+ completed_count = 0
327+ total_expected = len (fresh_dataset )
328+
329+ while completed_count < total_expected :
330+ finished_row = await queue .get ()
331+
332+ # only permanent failure rows are put on the queue, so we can check for them here
333+ if finished_row .rollout_status and finished_row .rollout_status .status == "error" :
334+ if os .getenv ("EP_FAIL_ON_PERMANENT_FAILURE" , "true" ) != "false" :
335+ raise RuntimeError (
336+ f"Rollout { finished_row .execution_metadata .rollout_id } failed after { max_retry } retries. Errors: { finished_row .rollout_status .termination_reason } "
337+ )
338+
339+ completed_count += 1
340+ yield finished_row
341+
342+ await processor_task # explicitly wait for task completion and catch any exceptions
343+
344+ finally :
345+ # processor clean up after themselves if they have a cleanup method
346+ if hasattr (rollout_processor , "cleanup" ):
347+ rollout_processor .cleanup ()
348+
272349 combinations = generate_combinations ()
273350 if len (combinations ) == 0 :
274351 raise ValueError (
@@ -410,6 +487,8 @@ def _log_eval_error(
410487 kwargs = rollout_processor_kwargs or {},
411488 )
412489
490+ max_retry = int (os .getenv ("EP_MAX_RETRY" , "0" ))
491+
413492 for i in range (num_runs ):
414493 # Regenerate outputs each run by deep-copying the pristine dataset
415494 # so model responses are not reused across runs.
@@ -428,15 +507,15 @@ def _log_eval_error(
428507 for row in fresh_dataset :
429508 active_logger .log (row )
430509
431- rollout_result = rollout_processor (fresh_dataset , config )
432-
433510 if mode == "pointwise" :
434511 # Pointwise mode, rollouts will return as they complete so we can pipeline evaluation_test execution
435512 semaphore = asyncio .Semaphore (max_concurrent_rollouts )
436513 tasks = []
437514
438515 async def _execute_with_semaphore (row ):
439516 async with semaphore :
517+ # NOTE: we will still evaluate errored rows (give users control over this)
518+ # i.e., they can choose to give EvaluateResult.score = 0 for errored rows in their test_func
440519 result = await execute_with_params (
441520 test_func ,
442521 processed_row = row ,
@@ -448,17 +527,23 @@ async def _execute_with_semaphore(row):
448527 )
449528 return result
450529
451- async for row in rollout_processor (fresh_dataset , config ):
530+ # Use wrapper that handles retry logic internally
531+ async for row in rollout_processor_with_retry (
532+ rollout_processor , fresh_dataset , config , max_retry
533+ ):
452534 tasks .append (asyncio .create_task (_execute_with_semaphore (row )))
453535
454536 all_results [i ] = await asyncio .gather (* tasks )
455537
456538 else :
457539 # Batch mode: collect all results first, then evaluate (no pipelining)
458540 input_dataset = []
459- async for row in rollout_result :
541+ async for row in rollout_processor_with_retry (
542+ rollout_processor , fresh_dataset , config , max_retry
543+ ):
460544 input_dataset .append (row )
461-
545+ # NOTE: we will still evaluate errored rows (give users control over this)
546+ # i.e., they can choose to give EvaluateResult.score = 0 for errored rows in their test_func
462547 results = await execute_with_params (
463548 test_func ,
464549 processed_dataset = input_dataset ,
@@ -530,7 +615,7 @@ async def _execute_with_semaphore(row):
530615 should_print = os .getenv ("EP_PRINT_SUMMARY" ) == "1"
531616 summary_path = os .getenv ("EP_SUMMARY_JSON" )
532617 suite_name = test_func .__name__
533- model_used = config .completion_params . model
618+ model_used = config .completion_params [ " model" ]
534619 total_rows = len ([item for sublist in all_results for item in sublist ])
535620 summary_obj = {
536621 "suite" : suite_name ,
@@ -990,7 +1075,7 @@ def _deep_update_dict(base: dict, override: dict) -> dict:
9901075 total_rows = len (all_results )
9911076 summary_obj = {
9921077 "suite" : suite_name ,
993- "model" : config .completion_params . model ,
1078+ "model" : config .completion_params [ " model" ] ,
9941079 "agg_score" : float (agg_score ) if agg_score is not None else None ,
9951080 "num_runs" : num_runs ,
9961081 "rows" : total_rows ,
@@ -1001,11 +1086,11 @@ def _deep_update_dict(base: dict, override: dict) -> dict:
10011086 if should_print :
10021087 if ci_low is not None and ci_high is not None :
10031088 print (
1004- f"EP Summary | suite={ suite_name } model={ config .completion_params . model } agg={ summary_obj ['agg_score' ]:.3f} ci95=[{ ci_low :.3f} ,{ ci_high :.3f} ] runs={ num_runs } rows={ total_rows } "
1089+ f"EP Summary | suite={ suite_name } model={ config .completion_params [ ' model' ] } agg={ summary_obj ['agg_score' ]:.3f} ci95=[{ ci_low :.3f} ,{ ci_high :.3f} ] runs={ num_runs } rows={ total_rows } "
10051090 )
10061091 else :
10071092 print (
1008- f"EP Summary | suite={ suite_name } model={ config .completion_params . model } agg={ summary_obj ['agg_score' ]:.3f} runs={ num_runs } rows={ total_rows } "
1093+ f"EP Summary | suite={ suite_name } model={ config .completion_params [ ' model' ] } agg={ summary_obj ['agg_score' ]:.3f} runs={ num_runs } rows={ total_rows } "
10091094 )
10101095 if summary_path :
10111096 import json as _json
@@ -1037,7 +1122,7 @@ def _extract_effort_tag(params: dict) -> str | None:
10371122 return None
10381123 return None
10391124
1040- model_slug = _sanitize_filename (config .completion_params . model )
1125+ model_slug = _sanitize_filename (config .completion_params [ " model" ] )
10411126 effort_tag = _extract_effort_tag (completion_params ) or ""
10421127 effort_suffix = f"__effort-{ _sanitize_filename (effort_tag )} " if effort_tag else ""
10431128 base_name = f"{ suite_name } __{ model_slug } { effort_suffix } __{ mode } __runs{ num_runs } .json"
0 commit comments