|
41 | 41 |
|
42 | 42 | RECEIPT_POLL_INTERVAL_SECONDS = 0.05 |
43 | 43 | RECEIPT_STABLE_INVALID_GRACE_SECONDS = 0.5 |
| 44 | +VALIDATION_ISSUE_KEYS = ( |
| 45 | + "boundary_violations", |
| 46 | + "hash_mismatches", |
| 47 | + "invalid_skills", |
| 48 | + "low_value_memory_writes", |
| 49 | +) |
44 | 50 |
|
45 | 51 |
|
46 | 52 | def enqueue_reflection_from_payload(payload: dict[str, Any], *, home: str | Path | None = None) -> dict[str, Any]: |
@@ -272,9 +278,45 @@ def _compact_validation(validation: dict[str, Any]) -> dict[str, Any]: |
272 | 278 | value = validation.get(key) |
273 | 279 | if value is not None: |
274 | 280 | compact[key] = value |
| 281 | + category = _validation_category(validation) |
| 282 | + if category: |
| 283 | + compact["category"] = category |
| 284 | + compact["issue_counts"] = _validation_issue_counts(validation) |
275 | 285 | return compact |
276 | 286 |
|
277 | 287 |
|
| 288 | +def _validation_category(validation: dict[str, Any]) -> str: |
| 289 | + """Classify validation failures by whether receipt or artifacts broke.""" |
| 290 | + status = str(validation.get("status") or "") |
| 291 | + if status not in {"failed", "partial"}: |
| 292 | + return "" |
| 293 | + reason = str(validation.get("reason") or "") |
| 294 | + if reason.startswith("receipt_") or reason in { |
| 295 | + "missing_receipt", |
| 296 | + "job_id_mismatch", |
| 297 | + "parent_session_id_mismatch", |
| 298 | + "child_thread_id_mismatch", |
| 299 | + }: |
| 300 | + return "child_receipt_contract" |
| 301 | + if validation.get("boundary_violations"): |
| 302 | + return "child_artifact_boundary" |
| 303 | + if validation.get("hash_mismatches") or validation.get("invalid_skills") or validation.get("low_value_memory_writes"): |
| 304 | + return "child_artifact_quality" |
| 305 | + if validation.get("error"): |
| 306 | + return "system_error" |
| 307 | + return "child_validation" |
| 308 | + |
| 309 | + |
| 310 | +def _validation_issue_counts(validation: dict[str, Any]) -> dict[str, int]: |
| 311 | + """Return compact counts for validation issue lists present in status.""" |
| 312 | + if not any(key in validation for key in VALIDATION_ISSUE_KEYS): |
| 313 | + return {} |
| 314 | + return { |
| 315 | + key: len(value) if isinstance(value := validation.get(key), list) else 0 |
| 316 | + for key in VALIDATION_ISSUE_KEYS |
| 317 | + } |
| 318 | + |
| 319 | + |
278 | 320 | def _wait_for_receipt(receipt_path: Path, *, timeout_seconds: float) -> None: |
279 | 321 | """Wait for an asynchronous app-server turn to finish writing its receipt.""" |
280 | 322 | deadline = time.monotonic() + max(0.0, timeout_seconds) |
@@ -381,13 +423,66 @@ def _load_receipt_object(receipt_path: Path) -> tuple[dict[str, Any] | None, str |
381 | 423 | return None, f"receipt_unreadable: {exc}" |
382 | 424 | if not text.strip(): |
383 | 425 | return None, "receipt_empty" |
384 | | - try: |
385 | | - loaded = json.loads(text) |
386 | | - except ValueError as exc: |
387 | | - return None, f"receipt_invalid_json: {exc}" |
388 | | - if not isinstance(loaded, dict): |
| 426 | + receipt, parse_error = _parse_receipt_object_text(text) |
| 427 | + if receipt is None: |
| 428 | + return None, f"receipt_invalid_json: {parse_error}" |
| 429 | + if not isinstance(receipt, dict): |
389 | 430 | return None, "receipt_not_object" |
390 | | - return loaded, "" |
| 431 | + return receipt, "" |
| 432 | + |
| 433 | + |
| 434 | +def _parse_receipt_object_text(text: str) -> tuple[dict[str, Any] | None, str]: |
| 435 | + """Parse common model-written receipt variants into a receipt object.""" |
| 436 | + last_error = "" |
| 437 | + for candidate in _receipt_text_candidates(text): |
| 438 | + try: |
| 439 | + loaded = json.loads(candidate) |
| 440 | + except ValueError as exc: |
| 441 | + last_error = str(exc) |
| 442 | + continue |
| 443 | + if isinstance(loaded, dict): |
| 444 | + return loaded, "" |
| 445 | + if isinstance(loaded, str): |
| 446 | + try: |
| 447 | + nested = json.loads(loaded) |
| 448 | + except ValueError as exc: |
| 449 | + last_error = str(exc) |
| 450 | + continue |
| 451 | + if isinstance(nested, dict): |
| 452 | + return nested, "" |
| 453 | + return None, last_error or "not a JSON object" |
| 454 | + |
| 455 | + |
| 456 | +def _receipt_text_candidates(text: str) -> list[str]: |
| 457 | + """Return raw, fenced, and escaped receipt text candidates.""" |
| 458 | + stripped = text.strip() |
| 459 | + candidates = [stripped] |
| 460 | + unfenced = _strip_json_code_fence(stripped) |
| 461 | + if unfenced != stripped: |
| 462 | + candidates.append(unfenced) |
| 463 | + for base in list(candidates): |
| 464 | + decoded = _decode_escaped_receipt_text(base) |
| 465 | + if decoded and decoded not in candidates: |
| 466 | + candidates.append(decoded) |
| 467 | + return candidates |
| 468 | + |
| 469 | + |
| 470 | +def _strip_json_code_fence(text: str) -> str: |
| 471 | + """Remove a surrounding Markdown JSON code fence from receipt text.""" |
| 472 | + lines = text.splitlines() |
| 473 | + if len(lines) >= 3 and lines[0].strip().startswith("```") and lines[-1].strip() == "```": |
| 474 | + return "\n".join(lines[1:-1]).strip() |
| 475 | + return text |
| 476 | + |
| 477 | + |
| 478 | +def _decode_escaped_receipt_text(text: str) -> str: |
| 479 | + """Decode receipt text that escaped quotes/newlines without string quotes.""" |
| 480 | + if "\\\"" not in text and "\\n" not in text: |
| 481 | + return "" |
| 482 | + try: |
| 483 | + return text.encode("utf-8").decode("unicode_escape") |
| 484 | + except UnicodeError: |
| 485 | + return "" |
391 | 486 |
|
392 | 487 |
|
393 | 488 | def _canonicalize_receipt_envelope( |
|
0 commit comments