3232while non-string values (JSON numbers) stay raw; all other arguments stay
3333raw.
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
3743is best-effort: distinct calls whose summaries collide after redaction or
3844truncation may be over-counted as redundant. A stable argument fingerprint
3945requires a producer-side change in ``src/agent/codex_agent_backend.py``, which
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
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
128145#: module docstring.
129146EXPECTED_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
133157class 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)
204230def _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+
405464def 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