Skip to content

Commit 7356361

Browse files
committed
Finished Error Handling
1 parent 180417f commit 7356361

8 files changed

Lines changed: 587 additions & 46 deletions

eval_protocol/mcp/execution/manager.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -138,7 +138,7 @@ async def _execute_with_semaphore(idx):
138138
if trajectory.terminated:
139139
if trajectory.termination_reason == TerminationReason.ERROR:
140140
evaluation_row.rollout_status.status = "error"
141-
evaluation_row.rollout_status.error_message = trajectory.control_plane_summary.get(
141+
evaluation_row.rollout_status.termination_reason = trajectory.control_plane_summary.get(
142142
"error_message", None
143143
)
144144
else:

eval_protocol/pytest/default_agent_rollout_processor.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -126,7 +126,7 @@ async def default_agent_rollout_processor(
126126
async def process_row(row: EvaluationRow) -> EvaluationRow:
127127
"""Process a single row with agent rollout."""
128128
agent = Agent(
129-
model=config.completion_params.model, row=row, config_path=config.mcp_config_path, logger=config.logger
129+
model=config.completion_params["model"], row=row, config_path=config.mcp_config_path, logger=config.logger
130130
)
131131
try:
132132
await agent.setup()
@@ -141,7 +141,8 @@ async def _sem_wrapper(r: EvaluationRow) -> EvaluationRow:
141141
try:
142142
return await process_row(r)
143143
except Exception as e:
144-
logger.exception(f"Error processing row {r.input_metadata.row_id}: {e}")
144+
r.rollout_status.status = "error"
145+
r.rollout_status.termination_reason = str(e)
145146
return r
146147

147148
# Create all tasks

eval_protocol/pytest/default_mcp_gym_rollout_processor.py

Lines changed: 72 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -6,12 +6,14 @@
66
import subprocess
77
import time
88
from pathlib import Path
9-
from typing import AsyncIterator, List, Optional
9+
from typing import Any, AsyncIterator, Dict, List, Optional
1010

1111
import eval_protocol as ep
1212
from eval_protocol.models import EvaluationRow, Message
1313
from eval_protocol.pytest.types import RolloutProcessorConfig
1414

15+
CURRENT_RUN_STATE: Dict[str, Any] = {}
16+
1517

1618
class MCPServerManager:
1719
"""Manages MCP server lifecycle for testing."""
@@ -204,41 +206,78 @@ async def default_mcp_gym_rollout_processor(
204206
Args:
205207
rows: List of EvaluationRow objects containing messages and dataset info in input_metadata
206208
config: RolloutProcessorConfig with model and other parameters
209+
- config.kwargs can include:
210+
- start_server (bool): If True, create fresh server and environments. If False, reuse existing ones. Default: True.
207211
208212
Returns:
209213
AsyncIterator of EvaluationRow objects with completed conversations
210214
"""
211-
if config.server_script_path is None:
212-
raise ValueError("server_script_path is required for default_mcp_gym_rollout_processor")
213-
server = MCPServerManager(config.server_script_path, port=9700, **(config.kwargs or {}))
214-
215-
try:
216-
server.start()
217-
218-
policy = ep.LiteLLMPolicy(
219-
model_id=config.completion_params.model,
220-
temperature=config.completion_params.get("temperature", 0.0),
221-
max_tokens=config.completion_params.get("max_tokens", 4096),
222-
reasoning_effort=config.completion_params.get("reasoning_effort", None),
223-
)
215+
start_server = config.kwargs.get("start_server", True) if config.kwargs else True
216+
if start_server:
217+
# Create fresh MCP server and environments for this run
218+
if config.server_script_path is None:
219+
raise ValueError("server_script_path is required for default_mcp_gym_rollout_processor")
224220

225-
# Create MCP environments directly from evaluation_rows
226-
envs = ep.make(
227-
"http://localhost:9700/mcp/",
228-
evaluation_rows=rows,
229-
model_id=policy.model_id,
230-
)
221+
server = MCPServerManager(config.server_script_path, port=9700, **(config.kwargs or {}))
231222

232-
# Run rollout with environments and policy
233-
async for evaluation_row in ep.rollout(
234-
envs,
235-
policy=policy,
236-
evaluation_rows=rows,
237-
steps=config.steps,
238-
max_concurrent_rollouts=config.max_concurrent_rollouts,
239-
):
240-
yield evaluation_row
241-
242-
finally:
243-
# Always clean up the server
244-
server.stop()
223+
try:
224+
server.start()
225+
226+
policy = ep.LiteLLMPolicy(
227+
model_id=config.completion_params.get("model", None),
228+
temperature=config.completion_params.get("temperature", 0.0),
229+
max_tokens=config.completion_params.get("max_tokens", 4096),
230+
reasoning_effort=config.completion_params.get("reasoning_effort", None),
231+
)
232+
233+
# Create MCP environments directly from evaluation_rows
234+
envs = ep.make(
235+
"http://localhost:9700/mcp/",
236+
evaluation_rows=rows,
237+
model_id=policy.model_id,
238+
)
239+
240+
# Store in current run state for reuse within this run
241+
CURRENT_RUN_STATE.update(
242+
{
243+
"server": server,
244+
"envs": envs,
245+
"policy": policy,
246+
}
247+
)
248+
249+
except Exception as e:
250+
server.stop()
251+
CURRENT_RUN_STATE.clear()
252+
raise e
253+
254+
else:
255+
# Reuse existing MCP environments for retry
256+
if not CURRENT_RUN_STATE:
257+
raise RuntimeError("Cannot retry without existing server/environments. Call with start_server=True first.")
258+
259+
server = CURRENT_RUN_STATE["server"]
260+
envs = CURRENT_RUN_STATE["envs"]
261+
policy = CURRENT_RUN_STATE["policy"]
262+
263+
# Run rollout with environments and policy (automatically resets environments)
264+
async for evaluation_row in ep.rollout(
265+
envs,
266+
policy=policy,
267+
evaluation_rows=rows,
268+
steps=config.steps,
269+
max_concurrent_rollouts=config.max_concurrent_rollouts,
270+
):
271+
yield evaluation_row
272+
273+
274+
# Add cleanup method directly to the function object
275+
def _cleanup_mcp_gym_rollout_processor():
276+
"""Cleanup function for MCP gym rollout processor"""
277+
if CURRENT_RUN_STATE and "server" in CURRENT_RUN_STATE:
278+
CURRENT_RUN_STATE["server"].stop()
279+
CURRENT_RUN_STATE.clear() # Clear for next run
280+
281+
282+
# Attach cleanup method to the processor function
283+
default_mcp_gym_rollout_processor.cleanup = _cleanup_mcp_gym_rollout_processor

eval_protocol/pytest/default_single_turn_rollout_process.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,8 @@ async def _sem_wrapper(r: EvaluationRow) -> EvaluationRow:
112112
try:
113113
return await process_row(r)
114114
except Exception as e:
115+
r.rollout_status.status = "error"
116+
r.rollout_status.termination_reason = str(e)
115117
return r
116118

117119
# Create all tasks

eval_protocol/pytest/evaluation_test.py

Lines changed: 95 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@
88
import re
99
import statistics
1010
import time
11+
from dataclasses import replace
1112
from typing import Any, Callable, Dict, List, Literal, Optional, Union
1213

1314
import 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"

eval_protocol/pytest/plugin.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,23 @@ def pytest_addoption(parser) -> None:
5959
"Values: low|medium|high"
6060
),
6161
)
62+
group.addoption(
63+
"--ep-max-retry",
64+
action="store",
65+
type=int,
66+
default=None,
67+
help=("Failed rollouts (with rollout_status.status == 'error') will be retried up to this many times."),
68+
)
69+
group.addoption(
70+
"--ep-fail-on-permanent-failure",
71+
action="store",
72+
default=None,
73+
choices=["true", "false"],
74+
help=(
75+
"Whether to fail the entire rollout when permanent failures occur after max retries. "
76+
"Default: true (fail on permanent failures). Set to 'false' to continue with remaining rollouts."
77+
),
78+
)
6279

6380

6481
def _normalize_max_rows(val: Optional[str]) -> Optional[str]:
@@ -100,6 +117,14 @@ def pytest_configure(config) -> None:
100117
if summary_json_path:
101118
os.environ["EP_SUMMARY_JSON"] = summary_json_path
102119

120+
max_retry = config.getoption("--ep-max-retry")
121+
if max_retry is not None:
122+
os.environ["EP_MAX_RETRY"] = str(max_retry)
123+
124+
fail_on_permanent_failure = config.getoption("--ep-fail-on-permanent-failure")
125+
if fail_on_permanent_failure is not None:
126+
os.environ["EP_FAIL_ON_PERMANENT_FAILURE"] = fail_on_permanent_failure
127+
103128
# Allow ad-hoc overrides of input params via CLI flags
104129
try:
105130
import json as _json

0 commit comments

Comments
 (0)