"""Strict-parse + retry-once helper for V1 worker outputs. Defense in depth: - Primary enforcement is the per-role MCP response builder (built in Phase 1a) which validates at construction time and emits canonical JSON via ``{role}_finalize()``. - This module strict-parses whatever JSON the worker emitted (whether via MCP or raw stdout). Catches any MCP bug AND legacy raw-JSON paths. Retry policy (v4+v6): - On first ValidationError: re-invoke the worker with a corrective prompt template that quotes the Pydantic error. Same model, same tier, temp=0 (caller's responsibility). - On second ValidationError: raise ``ContractValidationError`` → worker controller writes ``status='failed'`` ``outcome='contract-violation'`` → master transitions workflow to STUCK. """ from __future__ import annotations import json from typing import Callable, TypeVar from pydantic import BaseModel, ValidationError T = TypeVar("T", bound=BaseModel) class ContractValidationError(Exception): """Raised when strict-parse fails — twice (after retry) for retry helper, or unconditionally for the no-retry helper. Carries both the raw output(s) and the underlying ValidationError(s) for operator inspection via ``controller-cli stuck`` / ``controller-cli tail-events``. """ def __init__( self, model_class: type[BaseModel], attempts: list[tuple[str, ValidationError]], ) -> None: self.model_class = model_class self.attempts = attempts msg_lines = [ f"strict-parse failed for {model_class.__name__} after " f"{len(attempts)} attempt(s):", ] for idx, (_raw, err) in enumerate(attempts, 1): msg_lines.append(f" attempt {idx}:") for line in str(err).splitlines(): msg_lines.append(f" {line}") super().__init__("\n".join(msg_lines)) def strict_parse(model_class: type[T], raw: str | bytes) -> T: """Parse ``raw`` against ``model_class``. Raises ``ContractValidationError`` on failure (no retry). Use this for code paths where retry doesn't make sense — e.g., parsing the MCP's ``finalize()`` output (the MCP already validated; if it doesn't match Pydantic that's an MCP bug, not something a corrective prompt could fix). """ text = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else raw try: return model_class.model_validate_json(text) except ValidationError as exc: raise ContractValidationError(model_class, [(text, exc)]) from exc CORRECTIVE_PROMPT_TEMPLATE = """\ Your previous response failed schema validation. Validation error: {validation_error} Schema (what was expected): {schema} Your previous response (truncated to first 500 chars): {previous_output_preview} Please re-emit the same content as valid JSON matching the schema above. Reply with ONLY the JSON object — no prose, no markdown, no code fences. """ def build_corrective_prompt( model_class: type[BaseModel], previous_output: str, validation_error: ValidationError, ) -> str: """Build the corrective prompt for a retry. Pure function for testability.""" schema = json.dumps(model_class.model_json_schema(), indent=2) preview = previous_output[:500] if len(previous_output) > 500: preview += f"\n... ({len(previous_output) - 500} more chars truncated)" return CORRECTIVE_PROMPT_TEMPLATE.format( validation_error=str(validation_error), schema=schema, previous_output_preview=preview, ) def strict_parse_with_retry( model_class: type[T], raw: str | bytes, retry_call: Callable[[str], str], *, min_remaining_ttl_seconds: float = 30.0, remaining_ttl_seconds: float | None = None, ) -> T: """Parse ``raw``; on ValidationError, build a corrective prompt and call ``retry_call(corrective_prompt)`` for one retry; parse that result. Raises ``ContractValidationError`` on second failure. ``retry_call(corrective_prompt) -> raw_response_text`` is the worker's "issue the same OpenCode prompt with this corrective addendum" function. Caller supplies it so this module stays decoupled from OpenCode. TTL guard (v6): if ``remaining_ttl_seconds is not None`` and ``remaining_ttl_seconds < min_remaining_ttl_seconds``, the retry is skipped and ``ContractValidationError`` is raised with only the first attempt recorded. The worker controller maps this to ``outcome='ttl-insufficient-for-retry'`` which the master re-queues without a pickup-count penalty. """ text = raw.decode("utf-8") if isinstance(raw, (bytes, bytearray)) else raw try: return model_class.model_validate_json(text) except ValidationError as first_err: attempts: list[tuple[str, ValidationError]] = [(text, first_err)] if ( remaining_ttl_seconds is not None and remaining_ttl_seconds < min_remaining_ttl_seconds ): raise ContractValidationError(model_class, attempts) corrective = build_corrective_prompt(model_class, text, first_err) try: retry_text = retry_call(corrective) except Exception: # retry_call itself failed (transport error, OpenCode error). # Propagate as ContractValidationError with the first attempt # recorded; caller's outcome classification handles the # transport-error case separately. raise ContractValidationError(model_class, attempts) try: return model_class.model_validate_json(retry_text) except ValidationError as second_err: attempts.append((retry_text, second_err)) raise ContractValidationError(model_class, attempts) from second_err