Files
cleveragents-core/tools/controller/contracts/parse.py
T
drew 0bc734c020 style: ruff format the controller-state-machine branch (288 files)
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>
2026-05-20 00:09:17 -04:00

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