Skip to content

Commit 7edc9da

Browse files
committed
fix(evals): exempt pinned guarded probes, mark Codex guard outcomes unsupported, key recovered summary dicts
- Blocked calls targeting expected_guarded_stock (guarded / cached / failed) are the deliberate out-of-scope probe the sample itself requires, so they no longer count as wrong-stock violations; a clean success on the pinned stock is still reported and still escapes guarded_retry. - Guard-dependent outcome tags (guarded / cached / guarded_retry) are marked unsupported for Codex-shaped logs instead of emitting a false "not observed" regression, since the backend drops that metadata; retry stays scoreable because it derives from (tool, args-key) repeats. - Well-formed Codex arguments_summary previews are parsed back to the arguments dict and keyed through _args_key, so alias spellings and key order share one call identity; previews that no longer parse to an object keep the raw-preview fallback.
1 parent 0ab558c commit 7edc9da

2 files changed

Lines changed: 408 additions & 25 deletions

File tree

evals/agent_trajectory/metrics.py

Lines changed: 114 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -32,8 +32,14 @@
3232
while non-string values (JSON numbers) stay raw; all other arguments stay
3333
raw.
3434
35-
Codex App Server entries carry only ``arguments_summary`` — the redacted and
36-
truncated preview produced by ``redact_diagnostic_value`` — so their identity
35+
Codex App Server entries carry only ``arguments_summary`` — the preview
36+
produced by ``redact_diagnostic_value`` (the JSON serialization of the
37+
arguments dict, possibly redacted / truncated). A well-formed preview is
38+
parsed back to the original arguments dict *before* keying, so the recovered
39+
payload goes through the same ``_args_key`` canonicalization as a runner
40+
payload: alias spellings of ``stock_code`` and argument insertion order do
41+
not split call identity. Previews that no longer parse to an object
42+
(truncated / redacted) fall back to keying by the raw preview string, which
3743
is best-effort: distinct calls whose summaries collide after redaction or
3844
truncation may be over-counted as redundant. A stable argument fingerprint
3945
requires a producer-side change in ``src/agent/codex_agent_backend.py``, which
@@ -76,7 +82,13 @@
7682
bypassed, or the call provably gets through it) and does not satisfy it. A
7783
required outcome that is not observed is reported as a violation, so a
7884
golden sample that declares guard / cache / retry expectations cannot be
79-
passed by a trajectory that skips the behaviour it describes.
85+
passed by a trajectory that skips the behaviour it describes. Codex-shaped
86+
logs cannot observe guard-dependent tags (``guarded`` / ``cached`` /
87+
``guarded_retry``) because the producer drops that metadata; declaring them
88+
for such a log is reported as an explicit unsupported violation instead of
89+
a false "not observed" regression. ``retry`` stays scoreable for Codex
90+
logs — it derives from (tool, args-key) repeats, which their entries do
91+
carry.
8092
* ``expected_hit_rate`` is stock-scoped: an expected tool counts as hit only
8193
when at least one of its calls references ``golden.stock_code`` — matched
8294
exactly against the dedicated ``stock_code`` argument field of runner
@@ -92,7 +104,12 @@
92104
wrong-stock. Entries with no stock evidence at all keep the name-only
93105
tolerance, and *every* call of an expected tool whose stock resolves to a
94106
different code is reported in a violation — one matching call does not
95-
legitimize cross-stock calls of the same tool.
107+
legitimize cross-stock calls of the same tool. The one exception is the
108+
sample's own pinned out-of-scope stock (``expected_guarded_stock``): a call
109+
that targets it and stays blocked (``guarded`` / ``cached`` / failed) is
110+
the deliberate probe the task itself requires, so it is exempt from
111+
wrong-stock reporting; a clean success on the pinned stock is still
112+
reported (and escapes ``guarded_retry``).
96113
* Stock-code canonicalization: :func:`_canonicalize_stock_code` mirrors the
97114
runtime normalization chain
98115
(``src/agent/tools/execution._normalize_tool_stock_code`` delegating to
@@ -128,6 +145,13 @@
128145
#: module docstring.
129146
EXPECTED_OUTCOME_TAGS = ("guarded", "cached", "retry", "guarded_retry")
130147

148+
#: Outcome tags that depend on per-entry guard / cache metadata. The Codex
149+
#: App Server backend drops that metadata (it records only step / tool /
150+
#: arguments_summary / success / duration), so Codex-shaped logs can never
151+
#: observe these tags and such declarations are marked unsupported instead of
152+
#: failing as "not observed" (see the module docstring).
153+
GUARD_DEPENDENT_OUTCOME_TAGS = ("guarded", "cached", "guarded_retry")
154+
131155

132156
@dataclass
133157
class GoldenSample:
@@ -140,7 +164,9 @@ class GoldenSample:
140164
docstring); every declared outcome must be observable in the log.
141165
``expected_guarded_stock`` optionally pins the stock the guard must
142166
intercept (the task's out-of-scope call): when set, ``guarded_retry`` is
143-
only observed for guarded calls targeting that stock.
167+
only observed for guarded calls targeting that stock, and blocked calls
168+
(guarded / cached / failed) targeting it are exempt from wrong-stock
169+
reporting — the sample itself requires the probe.
144170
"""
145171

146172
id: str
@@ -204,16 +230,25 @@ def _args_key(arguments: Any, normalizer: Optional[Callable[[Any], str]] = None)
204230
def _entry_arguments(entry: Dict[str, Any]) -> Any:
205231
"""Extract the idempotent argument payload from a log entry.
206232
207-
Runner entries carry ``arguments`` (a dict); Codex App Server entries carry
208-
``arguments_summary`` (a string) instead. Falling back to ``{}`` for
209-
non-dict ``arguments`` would merge every summary-only entry into one key,
210-
so the summary is wrapped to keep call identity distinct.
233+
Runner entries carry ``arguments`` (a dict). Codex App Server entries
234+
carry ``arguments_summary`` — the JSON serialization of the arguments
235+
dict (possibly redacted / truncated) — so a well-formed preview is parsed
236+
back to the original dict and keyed through ``_args_key`` exactly like a
237+
runner payload (``stock_code`` canonicalization, sorted keys, argument
238+
insertion order must not split call identity). Previews that do not
239+
parse back to a dict fall back to a raw-preview wrapper so distinct
240+
calls whose summaries collide after truncation keep distinct identities
241+
(documented best-effort; falling back to ``{}`` would merge every
242+
summary-only entry into one key).
211243
"""
212244
arguments = entry.get("arguments")
213245
if isinstance(arguments, dict):
214246
return arguments
215247
summary = entry.get("arguments_summary")
216-
if summary:
248+
if isinstance(summary, str) and summary:
249+
recovered = _summary_dict(summary)
250+
if recovered is not None:
251+
return recovered
217252
return {"arguments_summary": summary}
218253
return arguments
219254

@@ -345,22 +380,35 @@ def _entry_matches_stock(
345380
_SUMMARY_NO_EVIDENCE = object()
346381

347382

348-
def _summary_stock_code(summary: str) -> Any:
349-
"""Recover the structured ``stock_code`` value from a Codex ``arguments_summary``.
383+
def _summary_dict(summary: str) -> Optional[Dict[str, Any]]:
384+
"""Parse a Codex ``arguments_summary`` back to the arguments dict.
350385
351386
``src/agent/codex_agent_backend`` stores
352-
``redact_diagnostic_value(record.arguments)`` — the JSON-serialized
353-
arguments dict — so a well-formed (untruncated, unredacted) preview
354-
parses back to the original payload. Returns ``_SUMMARY_NO_EVIDENCE``
355-
when the preview is not a JSON object with a ``stock_code`` key
356-
(truncated / redacted / non-object previews), leaving callers to their
357-
documented no-evidence tolerance or substring fallback.
387+
``redact_diagnostic_value(record.arguments)`` — the JSON serialization of
388+
the arguments dict — so a well-formed (untruncated, unredacted) preview
389+
recovers the original payload. Returns ``None`` when the preview is not
390+
intact JSON of an object (truncated / redacted / non-object previews);
391+
callers then fall back to their documented best-effort handling.
358392
"""
359393
try:
360394
parsed = json.loads(summary)
361395
except (TypeError, ValueError):
362-
return _SUMMARY_NO_EVIDENCE
363-
if isinstance(parsed, dict) and "stock_code" in parsed:
396+
return None
397+
if isinstance(parsed, dict):
398+
return parsed
399+
return None
400+
401+
402+
def _summary_stock_code(summary: str) -> Any:
403+
"""Recover the structured ``stock_code`` value from a Codex ``arguments_summary``.
404+
405+
Returns ``_SUMMARY_NO_EVIDENCE`` when the preview does not parse back to
406+
a JSON object with a ``stock_code`` key (truncated / redacted /
407+
non-object previews), leaving callers to their documented no-evidence
408+
tolerance or substring fallback.
409+
"""
410+
parsed = _summary_dict(summary)
411+
if parsed is not None and "stock_code" in parsed:
364412
return parsed["stock_code"]
365413
return _SUMMARY_NO_EVIDENCE
366414

@@ -402,6 +450,17 @@ def _entry_mismatches_stock(
402450
return False
403451

404452

453+
def _entry_stayed_blocked(entry: Dict[str, Any], success: bool) -> bool:
454+
"""True when the call did not execute cleanly (guarded / cached / failed).
455+
456+
A blocked call proves nothing about whether the scope guard can be
457+
bypassed, while a clean success of an out-of-scope call does — the same
458+
predicate drives the ``guarded_retry`` escape tracking and the
459+
wrong-stock exemption for pinned out-of-scope probes.
460+
"""
461+
return bool(entry.get("cached") or entry.get("guarded") or not success)
462+
463+
405464
def compute_trajectory_metrics(
406465
log: List[Dict[str, Any]],
407466
golden: GoldenSample,
@@ -474,22 +533,37 @@ def compute_trajectory_metrics(
474533
if not isinstance(entry, dict):
475534
continue
476535
tool = entry.get("tool") or ""
536+
success = bool(entry.get("success", True))
477537
if tool and tool not in used_tools:
478538
used_tools.append(tool)
479539
if stock_code and tool and tool not in stock_hit and _entry_matches_stock(entry, stock_code, normalizer):
480540
stock_hit[tool] = True
481541
# Every call of an expected tool whose stock resolves to a different
482542
# code is reported — a matching call elsewhere must not legitimize
483543
# cross-stock usage of the same tool (stock_hit only tracks whether
484-
# the tool ever matched, not whether every call did).
485-
if stock_code and tool in expected_set and _entry_mismatches_stock(entry, stock_code, normalizer):
544+
# the tool ever matched, not whether every call did). The one
545+
# exception is the sample's pinned out-of-scope stock: a blocked
546+
# probe of it (guarded / cached / failed) is exactly what the golden
547+
# requires, so it is not a wrong-stock violation — but a clean
548+
# success on the pinned stock is still reported (and escapes
549+
# guarded_retry).
550+
pinned_probe_blocked = (
551+
guarded_stock_valid
552+
and _entry_matches_stock(entry, guarded_stock, normalizer)
553+
and _entry_stayed_blocked(entry, success)
554+
)
555+
if (
556+
stock_code
557+
and tool in expected_set
558+
and not pinned_probe_blocked
559+
and _entry_mismatches_stock(entry, stock_code, normalizer)
560+
):
486561
wrong_stock_calls.append(tool)
487562
step = _coerce_step(entry.get("step"))
488563
if step and step not in seen_steps:
489564
seen_steps.add(step)
490565
distinct_steps += 1
491566
max_step = max(max_step, step)
492-
success = bool(entry.get("success", True))
493567
if not success:
494568
failed_calls += 1
495569
if entry.get("cached"):
@@ -509,7 +583,7 @@ def compute_trajectory_metrics(
509583
# the golden contract requires every out-of-scope call to stay
510584
# blocked at all times, and a pre-guard success proves the call can
511585
# get through the guard.
512-
if not (entry.get("cached") or entry.get("guarded") or not success):
586+
if not _entry_stayed_blocked(entry, success):
513587
key_guarded_escaped.add(key)
514588
# Record the occurrence index of the (first) guarded call so the
515589
# "guarded_retry" outcome can require a *later* occurrence of the
@@ -605,6 +679,19 @@ def compute_trajectory_metrics(
605679
# stock binding for guarded_retry.
606680
if golden.expected_guarded_stock is not None and not guarded_stock_valid:
607681
violations.append("expected_guarded_stock must be a non-empty string")
682+
# Codex App Server entries carry no guarded / cached metadata (the
683+
# backend records only step / tool / arguments_summary / success /
684+
# duration), so guard-dependent outcome tags can never be observed from a
685+
# Codex-shaped log; mark them unsupported instead of emitting a false
686+
# "expected outcomes not observed" regression. ``retry`` stays
687+
# scoreable: it derives from (tool, args-key) repeats, which Codex
688+
# entries do carry.
689+
unsupported_outcomes = [t for t in outcomes if t in GUARD_DEPENDENT_OUTCOME_TAGS] if codex_shaped else []
690+
if unsupported_outcomes:
691+
violations.append(
692+
"expected outcomes unsupported for Codex App Server logs "
693+
"(backend drops guarded/cached metadata): " + ", ".join(unsupported_outcomes)
694+
)
608695
observed = []
609696
if guarded_calls:
610697
observed.append("guarded")
@@ -614,7 +701,9 @@ def compute_trajectory_metrics(
614701
observed.append("retry")
615702
if any(idx < key_counts.get(k, 0) and k not in key_guarded_escaped for k, idx in key_guarded_at.items()):
616703
observed.append("guarded_retry")
617-
missing_outcomes = [t for t in outcomes if t in EXPECTED_OUTCOME_TAGS and t not in observed]
704+
missing_outcomes = [
705+
t for t in outcomes if t in EXPECTED_OUTCOME_TAGS and t not in observed and t not in unsupported_outcomes
706+
]
618707
if missing_outcomes:
619708
violations.append(f"expected outcomes not observed: {', '.join(missing_outcomes)}")
620709

0 commit comments

Comments
 (0)