-
Notifications
You must be signed in to change notification settings - Fork 54
[FEAT]: Add durable terminal evaluation contract #146
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,7 +15,9 @@ result.safe # bool — did the agent behave safely? | |
| result.status # SafetyStatus (SAFE, UNSAFE, UNDETERMINED, ERROR) | ||
| result.summary # str — human-readable one-liner | ||
| result.observability_level # ObservabilityLevel (what the adapter saw) | ||
| result.terminal_evaluation # EvalResult | None — terminal evaluator output | ||
| result.turns # list[Turn] — full conversation | ||
| result.trace_end_reason # TraceEndReason | None — why the trace ended | ||
| result.duration_seconds # float — execution wall-clock time | ||
| result.harm_category # HarmCategory | str | None | ||
| result.strategy # str — "xpia", "probe", etc. | ||
|
|
@@ -49,9 +51,31 @@ for turn in result.turns: | |
| turn.response.text # What came back | ||
| turn.response.tool_calls # Tool invocations observed | ||
| turn.eval_result # EvalResult for this turn, or None | ||
| turn.eval_purpose # EvaluationPurpose | None | ||
| turn.turn_number # 0-indexed position | ||
| ``` | ||
|
|
||
| `terminal_evaluation` is the evaluator output for the terminal trace. It is an | ||
| input to the final status, not a duplicate status: execution policy can still | ||
| adjust the verdict, and `result.status` remains authoritative. | ||
|
|
||
| This layer makes terminal provenance durable before changing execution | ||
| cadence. Existing prefix-evaluated strategies leave these fields as `None` | ||
| until their follow-up migration; manually constructed and error results may do | ||
| the same intentionally. | ||
|
|
||
| Online evaluations attached to turns are available as | ||
| `result.turn_evaluations`. The older `result.eval_results` property remains a | ||
| compatibility view of the same turn-level list and intentionally excludes the | ||
| terminal evaluation. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Since we aren't following an explicit deprecation strategy right now - can we remove this |
||
|
|
||
| `TraceEndReason.MAX_TURNS_REACHED` records budget truncation. It does not by | ||
| itself claim that the scenario reached semantic completion; each execution | ||
| strategy decides how that truncated trace affects status. | ||
|
|
||
| Trial population references require a non-empty ID, a positive size, an index | ||
| within that size, and a finite threshold from 0.0 through 1.0. | ||
|
|
||
| ### Observability Gaps on a Passing Run | ||
|
|
||
| A run can resolve `SAFE` while part of the evaluation was never observable. Such a run is graded as a pass: `result.safe` is `True`, the result line reads `PASS`, an execution population counts it toward the pass rate, and pytest exits zero. `result.summary` names the gap, and `turn.eval_result.undetermined_operands` carries it one reason at a time, so a caller that wants to fail on it has to say so: | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,124 @@ | ||
| # Copyright (c) Microsoft Corporation. | ||
| # Licensed under the MIT license. | ||
|
|
||
| """Shared validation for trial population configuration and provenance.""" | ||
|
|
||
| from __future__ import annotations | ||
|
|
||
| import math | ||
|
|
||
|
|
||
| def validate_population_id(value: object) -> str: | ||
| """Validate and return a population identifier. | ||
|
|
||
| Returns: | ||
| str: Validated population identifier. | ||
|
|
||
| Raises: | ||
| TypeError: If ``value`` is not a string. | ||
| ValueError: If ``value`` is empty or exceeds the transport bound. | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: only returns ValueError is is empty - I don't see any logic around a transport bound? |
||
| """ | ||
| if not isinstance(value, str): | ||
| msg = "population id must be a string" | ||
| raise TypeError(msg) | ||
| if not value: | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. nit: Check for |
||
| msg = "population id must be non-empty" | ||
| raise ValueError(msg) | ||
| return value | ||
|
|
||
|
|
||
| def validate_population_size( | ||
| value: object, | ||
| *, | ||
| name: str, | ||
| allow_zero: bool = False, | ||
| ) -> int: | ||
| """Validate and return a population size. | ||
|
|
||
| Returns: | ||
| int: Validated population size. | ||
|
|
||
| Raises: | ||
| TypeError: If ``value`` is not a non-boolean integer. | ||
| ValueError: If ``value`` is outside the supported range. | ||
| """ | ||
| if type(value) is not int: | ||
| msg = f"{name} must be a non-boolean integer" | ||
| raise TypeError(msg) | ||
| minimum = 0 if allow_zero else 1 | ||
| if value < minimum: | ||
| if minimum == 0: | ||
| msg = f"{name} must be greater than or equal to 0" | ||
| else: | ||
| msg = f"{name} must be greater than or equal to 1" | ||
| raise ValueError(msg) | ||
| return value | ||
|
|
||
|
|
||
| def validate_population_threshold(value: object, *, name: str) -> float: | ||
| """Validate and return a finite population threshold. | ||
|
|
||
| Returns: | ||
| float: Normalized population threshold. | ||
|
|
||
| Raises: | ||
| TypeError: If ``value`` is not a non-boolean number. | ||
| ValueError: If ``value`` is non-finite or outside [0.0, 1.0]. | ||
| """ | ||
| if isinstance(value, bool) or not isinstance(value, int | float): | ||
| msg = f"{name} must be a number" | ||
| raise TypeError(msg) | ||
| try: | ||
| normalized = float(value) | ||
| except OverflowError as exc: | ||
| msg = f"{name} must be finite" | ||
| raise ValueError(msg) from exc | ||
| if not math.isfinite(normalized): | ||
| msg = f"{name} must be finite" | ||
| raise ValueError(msg) | ||
| if not 0.0 <= normalized <= 1.0: | ||
| msg = f"{name} must be between 0.0 and 1.0" | ||
| raise ValueError(msg) | ||
| return normalized | ||
|
|
||
|
|
||
| def validate_population_index(value: object, *, size: int) -> int: | ||
| """Validate and return a population member index. | ||
|
|
||
| Returns: | ||
| int: Validated population index. | ||
|
|
||
| Raises: | ||
| TypeError: If ``value`` is not a non-boolean integer. | ||
| ValueError: If ``value`` falls outside the population. | ||
| """ | ||
| if type(value) is not int: | ||
| msg = "population index must be an integer" | ||
| raise TypeError(msg) | ||
| if not 0 <= value < size: | ||
| msg = "population index must be between 0 and size - 1" | ||
| raise ValueError(msg) | ||
| return value | ||
|
|
||
|
|
||
| def validate_population_parameters( | ||
| *, | ||
| size: object, | ||
| threshold: object, | ||
| size_name: str, | ||
| threshold_name: str, | ||
| allow_empty: bool = False, | ||
| ) -> tuple[int, float]: | ||
| """Validate and normalize shared population parameters. | ||
|
|
||
| Returns: | ||
| tuple[int, float]: Validated size and normalized threshold. | ||
| """ | ||
| return ( | ||
| validate_population_size( | ||
| size, | ||
| name=size_name, | ||
| allow_zero=allow_empty, | ||
| ), | ||
| validate_population_threshold(threshold, name=threshold_name), | ||
| ) | ||
Uh oh!
There was an error while loading. Please reload this page.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Naming thoughts: Since this is the final-trace evaluator output vs turn-level evals...what do you think about naming this
final_trace_evaluation? Terminal does not read to me as common of language as "trace" in our docs. It can even befinal_evaluationor something without trace/terminal...but if we use trace then it would relate it totrace_end_reasonwhile still adding clarity.