0bc734c020
Applies `ruff format` to the accumulated formatting debt on this branch. Formatting-only — no behavioral changes. Required for CI/lint's format gate (`nox -s format -- --check`), which the branch was failing on 288 tracked files that drifted from ruff's canonical style. In-progress WIP files are intentionally excluded so this commit stays a clean formatting-only diff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
159 lines
5.7 KiB
Python
159 lines
5.7 KiB
Python
"""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
|