Skip to content

Commit f74481c

Browse files
author
Dylan Huang
committed
save
1 parent 6e557b8 commit f74481c

9 files changed

Lines changed: 1530 additions & 55 deletions

File tree

eval_protocol/mcp/execution/manager.py

Lines changed: 7 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -20,7 +20,7 @@
2020
from vendor.tau2.data_model.message import AssistantMessage, UserMessage
2121
from vendor.tau2.user.user_simulator import UserSimulator
2222

23-
from ...models import EvaluationRow, InputMetadata, Message, RolloutStatus
23+
from ...models import EvaluationRow, InputMetadata, Message, Status
2424
from ...types import TerminationReason, Trajectory, NonSkippableException
2525

2626
if TYPE_CHECKING:
@@ -136,15 +136,14 @@ async def _execute_with_semaphore(idx):
136136
}
137137

138138
if trajectory.terminated:
139-
evaluation_row.rollout_status.termination_reason = trajectory.termination_reason
140-
evaluation_row.rollout_status.status = RolloutStatus.Status.FINISHED
141-
# preserve the true error mesage if there are any
139+
extra_info = None
142140
if trajectory.control_plane_summary.get("error_message"):
143-
evaluation_row.rollout_status.extra_info = {
144-
"error_message": trajectory.control_plane_summary.get("error_message")
145-
}
141+
extra_info = {"error_message": trajectory.control_plane_summary.get("error_message")}
142+
evaluation_row.rollout_status = Status.rollout_finished(
143+
termination_reason=trajectory.termination_reason, extra_info=extra_info
144+
)
146145
else:
147-
evaluation_row.rollout_status.status = RolloutStatus.Status.RUNNING
146+
evaluation_row.rollout_status = Status.rollout_running()
148147

149148
return evaluation_row
150149

eval_protocol/models.py

Lines changed: 204 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,199 @@
1515
from eval_protocol.types import TerminationReason
1616

1717

18+
class ErrorInfo(BaseModel):
19+
"""
20+
AIP-193 ErrorInfo model for structured error details.
21+
22+
This model follows Google's AIP-193 standard for ErrorInfo:
23+
https://google.aip.dev/193#errorinfo
24+
25+
Attributes:
26+
reason (str): A short snake_case description of the cause of the error.
27+
domain (str): The logical grouping to which the reason belongs.
28+
metadata (Dict[str, Any]): Additional dynamic information as context.
29+
"""
30+
31+
reason: str = Field(..., description="Short snake_case description of the error cause")
32+
domain: str = Field(..., description="Logical grouping for the error reason")
33+
metadata: Dict[str, Any] = Field(default_factory=dict, description="Additional dynamic information as context")
34+
35+
def to_aip193_format(self) -> Dict[str, Any]:
36+
"""Convert to AIP-193 format with @type field."""
37+
return {
38+
"@type": "type.googleapis.com/google.rpc.ErrorInfo",
39+
"reason": self.reason,
40+
"domain": self.domain,
41+
"metadata": self.metadata,
42+
}
43+
44+
@classmethod
45+
def termination_reason(cls, reason: str) -> "ErrorInfo":
46+
"""Create an ErrorInfo for termination reason."""
47+
return cls(reason="TERMINATION_REASON", domain="evalprotocol.io", metadata={"termination_reason": reason})
48+
49+
@classmethod
50+
def extra_info(cls, metadata: Dict[str, Any]) -> "ErrorInfo":
51+
"""Create an ErrorInfo for extra information."""
52+
return cls(reason="EXTRA_INFO", domain="evalprotocol.io", metadata=metadata)
53+
54+
@classmethod
55+
def rollout_error(cls, metadata: Dict[str, Any]) -> "ErrorInfo":
56+
"""Create an ErrorInfo for rollout errors."""
57+
return cls(reason="ROLLOUT_ERROR", domain="evalprotocol.io", metadata=metadata)
58+
59+
@classmethod
60+
def stopped_reason(cls, reason: str) -> "ErrorInfo":
61+
"""Create an ErrorInfo for stopped reason."""
62+
return cls(reason="STOPPED", domain="evalprotocol.io", metadata={"reason": reason})
63+
64+
65+
class Status(BaseModel):
66+
"""
67+
AIP-193 compatible Status model for standardized error responses.
68+
69+
This model follows Google's AIP-193 standard for error handling:
70+
https://google.aip.dev/193
71+
72+
Attributes:
73+
code (int): The status code, must be the numeric value of one of the elements
74+
of google.rpc.Code enum (e.g., 5 for NOT_FOUND).
75+
message (str): Developer-facing, human-readable debug message in English.
76+
details (List[Dict[str, Any]]): Additional error information, each packed in
77+
a google.protobuf.Any message format.
78+
"""
79+
80+
code: "Status.Code" = Field(..., description="The status code from google.rpc.Code enum")
81+
message: str = Field(..., description="Developer-facing, human-readable debug message in English")
82+
details: List[Dict[str, Any]] = Field(
83+
default_factory=list,
84+
description="Additional error information, each packed in a google.protobuf.Any message format",
85+
)
86+
87+
# Convenience constants for common status codes
88+
class Code(int, Enum):
89+
"""Common gRPC status codes as defined in google.rpc.Code"""
90+
91+
OK = 0
92+
CANCELLED = 1
93+
UNKNOWN = 2
94+
INVALID_ARGUMENT = 3
95+
DEADLINE_EXCEEDED = 4
96+
NOT_FOUND = 5
97+
ALREADY_EXISTS = 6
98+
PERMISSION_DENIED = 7
99+
RESOURCE_EXHAUSTED = 8
100+
FAILED_PRECONDITION = 9
101+
ABORTED = 10
102+
OUT_OF_RANGE = 11
103+
UNIMPLEMENTED = 12
104+
INTERNAL = 13
105+
UNAVAILABLE = 14
106+
DATA_LOSS = 15
107+
UNAUTHENTICATED = 16
108+
109+
# Custom codes for rollout states (using higher numbers to avoid conflicts)
110+
FINISHED = 100 # Custom code for rollout finished
111+
112+
@classmethod
113+
def rollout_running(cls) -> "Status":
114+
"""Create a status indicating the rollout is running."""
115+
return cls(code=cls.Code.OK, message="Rollout is running", details=[])
116+
117+
@classmethod
118+
def rollout_finished(
119+
cls, termination_reason: Optional[str] = None, extra_info: Optional[Dict[str, Any]] = None
120+
) -> "Status":
121+
"""Create a status indicating the rollout finished."""
122+
details = []
123+
if termination_reason:
124+
details.append(ErrorInfo.termination_reason(termination_reason).to_aip193_format())
125+
if extra_info:
126+
details.append(ErrorInfo.extra_info(extra_info).to_aip193_format())
127+
return cls(code=cls.Code.FINISHED, message="Rollout finished", details=details)
128+
129+
@classmethod
130+
def rollout_error(cls, error_message: str, extra_info: Optional[Dict[str, Any]] = None) -> "Status":
131+
"""Create a status indicating the rollout failed with an error."""
132+
details = []
133+
if extra_info:
134+
details.append(ErrorInfo.rollout_error(extra_info).to_aip193_format())
135+
return cls.error(error_message, details)
136+
137+
@classmethod
138+
def error(cls, error_message: str, details: Optional[List[Dict[str, Any]]] = None) -> "Status":
139+
"""Create a status indicating the rollout failed with an error."""
140+
return cls(code=cls.Code.INTERNAL, message=error_message, details=details)
141+
142+
@classmethod
143+
def rollout_stopped(cls, reason: str = "Rollout stopped") -> "Status":
144+
"""Create a status indicating the rollout was stopped."""
145+
details = [ErrorInfo.stopped_reason(reason).to_aip193_format()]
146+
return cls(code=cls.Code.CANCELLED, message=reason, details=details)
147+
148+
@classmethod
149+
def with_termination_reason(cls, termination_reason: str, extra_info: Optional[Dict[str, Any]] = None) -> "Status":
150+
"""Create a status indicating the rollout finished with termination reason."""
151+
details = [ErrorInfo.termination_reason(termination_reason).to_aip193_format()]
152+
153+
if extra_info:
154+
details.append(ErrorInfo.extra_info(extra_info).to_aip193_format())
155+
156+
return cls(code=cls.Code.FINISHED, message="Rollout finished", details=details)
157+
158+
def is_running(self) -> bool:
159+
"""Check if the status indicates the rollout is running."""
160+
return self.code == self.Code.OK and self.message == "Rollout is running"
161+
162+
def is_finished(self) -> bool:
163+
"""Check if the status indicates the rollout finished successfully."""
164+
return self.code == self.Code.FINISHED
165+
166+
def is_error(self) -> bool:
167+
"""Check if the status indicates the rollout failed with an error."""
168+
return self.code == self.Code.INTERNAL
169+
170+
def is_stopped(self) -> bool:
171+
"""Check if the status indicates the rollout was stopped."""
172+
return self.code == self.Code.CANCELLED
173+
174+
def get_termination_reason(self) -> Optional[str]:
175+
"""Extract termination reason from details if present."""
176+
for detail in self.details:
177+
if detail.get("@type") == "type.googleapis.com/google.rpc.ErrorInfo":
178+
metadata = detail.get("metadata", {})
179+
if detail.get("reason") == "TERMINATION_REASON" and "termination_reason" in metadata:
180+
return metadata["termination_reason"]
181+
return None
182+
183+
def get_extra_info(self) -> Optional[Dict[str, Any]]:
184+
"""Extract extra info from details if present."""
185+
for detail in self.details:
186+
if detail.get("@type") == "type.googleapis.com/google.rpc.ErrorInfo":
187+
metadata = detail.get("metadata", {})
188+
reason = detail.get("reason")
189+
# Skip termination_reason and stopped details, return other error info
190+
if reason not in ["TERMINATION_REASON", "STOPPED"]:
191+
return metadata
192+
return None
193+
194+
def __hash__(self) -> int:
195+
"""Generate a hash for the Status object."""
196+
# Use a stable hash based on code, message, and details
197+
import hashlib
198+
199+
# Create a stable string representation
200+
hash_data = f"{self.code}:{self.message}:{len(self.details)}"
201+
202+
# Add details content for more uniqueness
203+
for detail in sorted(self.details, key=lambda x: str(x)):
204+
hash_data += f":{str(detail)}"
205+
206+
# Generate hash
207+
hash_obj = hashlib.sha256(hash_data.encode("utf-8"))
208+
return int.from_bytes(hash_obj.digest()[:8], byteorder="big")
209+
210+
18211
class ChatCompletionContentPartTextParam(BaseModel):
19212
text: str = Field(..., description="The text content.")
20213
type: Literal["text"] = Field("text", description="The type of the content part.")
@@ -289,27 +482,6 @@ class ExecutionMetadata(BaseModel):
289482
)
290483

291484

292-
class RolloutStatus(BaseModel):
293-
"""Status of the rollout."""
294-
295-
"""
296-
running: Unfinished rollout which is still in progress.
297-
finished: Rollout finished.
298-
error: Rollout failed due to unexpected error. The rollout record should be discard.
299-
"""
300-
301-
class Status(str, Enum):
302-
RUNNING = "running"
303-
FINISHED = "finished"
304-
ERROR = "error"
305-
306-
status: Status = Field(Status.RUNNING, description="Status of the rollout.")
307-
termination_reason: Optional[TerminationReason] = Field(
308-
None, description="reason of the rollout status, mapped to values in TerminationReason"
309-
)
310-
extra_info: Optional[Dict[str, Any]] = Field(None, description="Extra information about the rollout status.")
311-
312-
313485
class EvaluationRow(BaseModel):
314486
"""
315487
Unified data structure for a single evaluation unit that contains messages,
@@ -334,9 +506,9 @@ class EvaluationRow(BaseModel):
334506
description="Metadata related to the input (dataset info, model config, session data, etc.).",
335507
)
336508

337-
rollout_status: RolloutStatus = Field(
338-
default_factory=RolloutStatus,
339-
description="The status of the rollout.",
509+
rollout_status: Status = Field(
510+
default_factory=Status.rollout_running,
511+
description="The status of the rollout following AIP-193 standards.",
340512
)
341513

342514
# Ground truth reference (moved from EvaluateResult to top level)
@@ -381,6 +553,14 @@ def is_trajectory_evaluation(self) -> bool:
381553
and len(self.evaluation_result.step_outputs) > 0
382554
)
383555

556+
def get_rollout_status(self) -> Status:
557+
"""Get the rollout status (backwards compatibility method)."""
558+
return self.rollout_status
559+
560+
def set_rollout_status(self, status: Status) -> None:
561+
"""Set the rollout status (backwards compatibility method)."""
562+
self.rollout_status = status
563+
384564
def get_conversation_length(self) -> int:
385565
"""Returns the number of messages in the conversation."""
386566
return len(self.messages)

eval_protocol/pytest/plugin.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -64,7 +64,7 @@ def pytest_addoption(parser) -> None:
6464
action="store",
6565
type=int,
6666
default=0,
67-
help=("Failed rollouts (with rollout_status.status == 'error') will be retried up to this many times."),
67+
help=("Failed rollouts (with rollout_status.code indicating error) will be retried up to this many times."),
6868
)
6969
group.addoption(
7070
"--ep-fail-on-max-retry",

eval_protocol/pytest/utils.py

Lines changed: 9 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@
66
from typing import Any, Callable, Dict, List, Literal, Optional, Union
77

88
from eval_protocol.dataset_logger.dataset_logger import DatasetLogger
9-
from eval_protocol.models import EvalMetadata, EvaluationRow, RolloutStatus
9+
from eval_protocol.models import EvalMetadata, EvaluationRow, Status
1010
from eval_protocol.pytest.rollout_processor import RolloutProcessor
1111
from eval_protocol.pytest.types import (
1212
CompletionParams,
@@ -257,7 +257,7 @@ async def retry_handler(failed_row: EvaluationRow):
257257
current_attempts = retry_counts.get(rollout_id, 0)
258258

259259
if current_attempts >= max_retry:
260-
assert failed_row.rollout_status and failed_row.rollout_status.status == RolloutStatus.Status.ERROR, (
260+
assert failed_row.rollout_status and failed_row.rollout_status.is_error(), (
261261
f"Rollout {failed_row.execution_metadata.rollout_id} did not fail with error status"
262262
)
263263
failed_permanently.append(failed_row)
@@ -273,11 +273,10 @@ async def retry_handler(failed_row: EvaluationRow):
273273

274274
try:
275275
retry_result = await retry_tasks[0]
276-
retry_result.rollout_status.status = RolloutStatus.Status.FINISHED
276+
retry_result.rollout_status = Status.rollout_finished()
277277
await queue.put(retry_result)
278278
except Exception as e:
279-
failed_row.rollout_status.status = RolloutStatus.Status.ERROR
280-
failed_row.rollout_status.termination_reason = str(e)
279+
failed_row.rollout_status = Status.rollout_error(str(e))
281280
asyncio.create_task(retry_handler(failed_row)) # retry failed, spawn another retry
282281

283282
async def initial_processor():
@@ -299,12 +298,11 @@ async def initial_processor():
299298

300299
try:
301300
result = await task
302-
result.rollout_status.status = RolloutStatus.Status.FINISHED
301+
result.rollout_status = Status.rollout_finished()
303302
await queue.put(result)
304303
except Exception as e:
305304
failed_row = fresh_dataset[task_index]
306-
failed_row.rollout_status.status = RolloutStatus.Status.ERROR
307-
failed_row.rollout_status.termination_reason = str(e)
305+
failed_row.rollout_status = Status.rollout_error(str(e))
308306
asyncio.create_task(retry_handler(failed_row)) # rollout errored, spawn retry task
309307

310308
processor_task = asyncio.create_task(initial_processor())
@@ -317,10 +315,11 @@ async def initial_processor():
317315
finished_row = await queue.get()
318316

319317
# only permanent failure rows are put on the queue, so we can check for them here
320-
if finished_row.rollout_status and finished_row.rollout_status.status == RolloutStatus.Status.ERROR:
318+
if finished_row.rollout_status and finished_row.rollout_status.is_error():
321319
if max_retry > 0 and os.getenv("EP_FAIL_ON_MAX_RETRY", "true") != "false":
320+
termination_reason = finished_row.rollout_status.get_termination_reason() or "Unknown error"
322321
raise RuntimeError(
323-
f"Rollout {finished_row.execution_metadata.rollout_id} failed after {max_retry} retries. Errors: {finished_row.rollout_status.termination_reason}"
322+
f"Rollout {finished_row.execution_metadata.rollout_id} failed after {max_retry} retries. Errors: {termination_reason}"
324323
)
325324

326325
completed_count += 1

0 commit comments

Comments
 (0)