Files
cleveragents-core/tools/controller/contracts/v1.py
T
drew a10758177c feat(controller): Phase 0 foundation — V1 contracts + CI gate enumeration
Lays the foundation for the planned controller + DB-owned PR state
machine rewrite (see .drew/controller_state_machine.md).

Phase 0a — Pydantic V1 worker I/O contracts (tools/controller/contracts/):
- v1.py: ImplementerInputV1/OutputV1, ReviewerInputV1/OutputV1,
  EstimatorOutputV1, IssueImplementerOutputV1,
  ConflictResolverInputV1/OutputV1, SummarizerInputV1/OutputV1.
  Plus CISummary / CIFailure / GateResult / FailedAssertion /
  FileLocation. All models use extra="forbid" + required
  output_version Literal discriminator (no defaults).
- parse.py: strict_parse() + strict_parse_with_retry() helpers.
  Corrective prompt template quotes the Pydantic ValidationError +
  the JSON schema. TTL guard skips retry when remaining time too
  short. Defense-in-depth around the response-builder MCPs that
  arrive in Phase 1a.

Phase 0b — Static CI parser-coverage discovery:
- NOX_SESSION_TO_PARSER map in tools/controller/ci_summary_parsers/.
  Derived from .forgejo/workflows/*.yml + noxfile.py. Covers ruff,
  pyright, behave, robot_framework, slipcover, vulture, radon,
  bandit, semgrep, build. Cron-only sessions (benchmark,
  benchmark_regression) are in NOX_SESSIONS_SKIP_COVERAGE.
- scripts/enumerate-ci-gates.py: reads the workflow YAML, joins
  against the map, exits non-zero on coverage gaps. Wired into
  tests so the actual .forgejo/ is regression-guarded.

63 new tests; full auto_agents suite still passes (2424).
2026-05-18 11:02:56 -04:00

507 lines
17 KiB
Python

"""V1 contracts for the controller's worker I/O.
Conventions:
- ``output_version: Literal["V1"]`` is a required discriminator field
on every worker output model (no default). Missing field → strict
parse failure → retry-once-with-corrective-prompt → STUCK on
second failure.
- ``ConfigDict(extra="forbid")`` on every model. Workers that emit
extra fields fail loud, not silently.
- No ``dict[str, Any]`` escape hatches. Add fields to V2 explicitly
when the need arises.
"""
from __future__ import annotations
from datetime import datetime
from typing import Annotated, Literal
from pydantic import BaseModel, ConfigDict, Field
# ─── shared building blocks ─────────────────────────────────────────
class FileLocation(BaseModel):
"""Repository-relative file reference with optional line context."""
model_config = ConfigDict(extra="forbid")
file_path: str
line_range: str | None = None
function_or_test: str | None = None
class FailedAssertion(BaseModel):
"""Test-failure-specific structured detail (pytest / behave / robot)."""
model_config = ConfigDict(extra="forbid")
test_name: str
expected: str | None = None
actual: str | None = None
assertion_excerpt: str = Field(
..., max_length=4096,
description="Raw assertion line + adjacent context (~10 lines)."
)
class CIFailureFinding(BaseModel):
"""One structured finding within a CIFailure.
A simple lint failure has one finding (the line that didn't lint).
A test session has many findings (one per failing test).
Bandit/semgrep findings have one per security issue.
"""
model_config = ConfigDict(extra="forbid")
error_class: str = Field(..., description="Tool-specific error code/class.")
summary: str = Field(..., max_length=512)
location: FileLocation | None = None
severity: Literal["info", "warning", "error", "blocker"] = "error"
class CIFailure(BaseModel):
"""Structured failure for one CI context.
v9/v10: structured fields are ALWAYS populated; ``raw_log_excerpt``
is supplementary context (NOT a fallback). No ``raw_only`` parser
exists — unmatched CI tools raise ``UnknownCIToolError`` →
workflow STUCK → operator writes a parser.
"""
model_config = ConfigDict(extra="forbid")
parser_used: str = Field(
..., description="Parser module name (e.g. 'ruff', 'pyright', 'behave')."
)
parser_version: str = Field(
..., description="From parser module's PARSER_VERSION constant."
)
# Required structured fields:
error_class: str = Field(
..., max_length=128,
description="Most-specific tool error class (e.g. 'AssertionError', 'LintRuleE501')."
)
summary_line: str = Field(
..., max_length=200,
description="One-line human-readable summary of the failure."
)
findings: list[CIFailureFinding] = Field(default_factory=list)
failing_locations: list[FileLocation] = Field(default_factory=list)
failed_assertions: list[FailedAssertion] = Field(default_factory=list)
# Supplementary (always present; smart-excerpt-selected by summarizer):
raw_log_excerpt: str = Field(
..., max_length=16384,
description="≤16KB excerpt of the failing job's log."
)
log_excerpt_lines: int = Field(..., ge=0)
# Multi-tool composite (e.g. nox security_scan = bandit+semgrep+vulture):
composite_findings: list["CIFailure"] = Field(
default_factory=list,
description="Nested findings when one gate runs multiple tools.",
)
# Forward-ref resolution for the recursive composite_findings field.
CIFailure.model_rebuild()
class GateResult(BaseModel):
"""One CI context's result."""
model_config = ConfigDict(extra="forbid")
name: str = Field(..., description="Forgejo context name, e.g. 'CI / Run Unit Tests'.")
status: Literal["passed", "failed", "error", "skipped", "pending"]
severity: Literal["info", "warning", "error", "blocker"] = "error"
target_url: str | None = Field(None, description="Forgejo link to logs (operator-visible).")
duration_seconds: float | None = Field(None, ge=0.0)
failure: CIFailure | None = Field(
None,
description="Populated iff status in {failed, error}.",
)
class CISummary(BaseModel):
"""Standardized CI state for a head_sha.
Built deterministically by ``_ci_summary.build()`` in the master's
prefetch path. Every worker that needs CI context consumes this
shape (no worker scans raw logs).
"""
model_config = ConfigDict(extra="forbid")
summary_version: Literal["V1"] = "V1"
head_sha: str
observed_at: datetime
overall_state: Literal["success", "failure", "error", "pending", "unknown"]
gates: list[GateResult]
# Convenience aggregates (computed by build()):
gates_total: int = Field(..., ge=0)
gates_passed: int = Field(..., ge=0)
gates_failed: int = Field(..., ge=0)
gates_skipped: int = Field(..., ge=0)
gates_pending: int = Field(..., ge=0)
# Parser-side metadata for debugging + drift detection:
parser_versions: dict[str, str] = Field(
default_factory=dict,
description="Per-parser version (e.g. {'ruff': 'v1', 'pyright': 'v1'}).",
)
# ─── shared worker fields ─────────────────────────────────────────────
class PriorAttemptsBlock(BaseModel):
"""Bounded representation of prior IMPLEMENTER attempts in worker input.
Semantics:
- "Prior attempts" always refers to PRIOR IMPLEMENTER attempts —
the worker outputs an implementer's previous tries against this
workflow. Reviewer / conflict-resolver / estimator each see this
block to know what's been tried.
- Reviewer's own prior verdicts live in ``ReviewerInputV1.prior_reviews``,
not here.
- Conflict-resolver's prior conflict attempts aren't tracked yet
(Phase 4 work).
v6: last 3 verbatim + older synthesized via summarizer; never
blocks the workflow on summarization failure (falls back to
verbatim-extra up to 6).
v9: reference-based for older CI summaries (latest carries full
CISummary; older lookup via ``ci_summary_id``).
"""
model_config = ConfigDict(extra="forbid")
verbatim: list["ImplementerOutputV1"] = Field(default_factory=list)
older_summary: str | None = None
older_summary_covers_through_attempt: int | None = Field(None, ge=0)
total_attempts: int = Field(..., ge=0)
class Review(BaseModel):
"""A blocking or advisory review on a PR.
Used in implementer/reviewer input to convey active reviewer
state. Distinct from ``ReviewerOutputV1`` (which is what the
reviewer agent emits).
"""
model_config = ConfigDict(extra="forbid")
review_id: int
reviewer_login: str
state: Literal[
"APPROVED", "REQUEST_CHANGES", "COMMENT", "PENDING", "DISMISSED"
]
body: str = ""
submitted_at: datetime | None = None
# ─── reviewer ─────────────────────────────────────────────────────────
class ReviewerInputV1(BaseModel):
"""Input to a reviewer worker session.
State machine guarantees ``ci_summary.overall_state == 'success'``
when this is invoked (reviewer never sees failing CI). The
``ci_summary`` field is typed Optional for contract consistency
with issue workflows.
"""
model_config = ConfigDict(extra="forbid")
input_version: Literal["V1"] = "V1"
workflow_id: int = Field(..., ge=0)
attempt_id: int = Field(..., ge=0)
attempt_number: int = Field(..., ge=1)
pr_number: int = Field(..., ge=1)
head_sha: str
ci_summary: CISummary | None = Field(
None,
description="None only when reviewer is invoked on a workflow with no CI yet (rare).",
)
# Worker-visible context:
diff_summary: str
full_diff: str | None = Field(
None,
description="May be omitted for very large PRs; reviewer falls back to diff_summary + file reads.",
)
prior_reviews: list["ReviewerOutputV1"] = Field(default_factory=list)
prior_implementer_attempts: PriorAttemptsBlock | None = None
implementer_claim: "ImplementerOutputV1 | None" = Field(
None,
description="Most-recent implementer output that triggered this review.",
)
# Worker plumbing:
workspace_dir: str
wallclock_budget_s: int = Field(..., ge=1)
class BlockingIssue(BaseModel):
"""One blocking concern raised by a reviewer."""
model_config = ConfigDict(extra="forbid")
file_path: str | None = None
line_range: str | None = None
description: str = Field(..., max_length=2048)
severity: Literal["error", "blocker"] # info/warning are not "blocking"
suggested_fix: str | None = None
class ReviewerOutputV1(BaseModel):
"""What the reviewer worker emits."""
model_config = ConfigDict(extra="forbid")
output_version: Literal["V1"]
verdict: Literal["approve", "request-changes", "comment", "abstain"]
blocking_issues: list[BlockingIssue] = Field(default_factory=list)
approved_at_sha: str | None = Field(
None,
description="Required iff verdict='approve'.",
)
suggested_next_action: Literal[
"merge", "wait-for-ci", "re-implement", "human-attention", "abandon"
]
confidence: Literal["high", "medium", "low"]
wallclock_seconds: float = Field(..., ge=0.0)
# ─── implementer ──────────────────────────────────────────────────────
class ImplementerInputV1(BaseModel):
"""Input to an implementer worker session."""
model_config = ConfigDict(extra="forbid")
input_version: Literal["V1"] = "V1"
workflow_id: int = Field(..., ge=0)
attempt_id: int = Field(..., ge=0)
attempt_number: int = Field(..., ge=1)
pr_number: int = Field(..., ge=1)
tier: Literal[0, 1, 2]
head_sha: str
head_ref: str
base_branch: str
# CI context — Optional for request-changes triggers on green-CI PRs:
ci_summary: CISummary | None = None
# Server-side computed convenience field (failing_gates filter).
# v9 fix: was @property in v8 (broken serialization); now real
# field populated by master prefetch.
failing_gates: list[GateResult] = Field(default_factory=list)
# Review context:
active_reviews: list[Review] = Field(default_factory=list)
pr_comments_since_last_attempt: list[str] = Field(default_factory=list)
prior_attempts: PriorAttemptsBlock
# Optional reviewer-scoping:
allowed_files: list[str] | None = None
# Worker plumbing:
diff_summary: str
workspace_dir: str
wallclock_budget_s: int = Field(..., ge=1)
class ImplementerOutputV1(BaseModel):
"""What an implementer worker emits.
Outcome-specific invariants (enforced by MCP at construction time
AND by ``model_validator`` at parse time, defense in depth):
- ``resolved`` → ≥1 commit and ≥1 file modified
- ``rebase-failed`` → handled by transitioning to CONFLICT_RESOLVING
- ``blocked`` → ≥1 entry in ``blockers``
- ``noop`` → 0 commits, 0 files modified, no blockers
- ``competence-failure`` → free-form; worker tried and gave up
"""
model_config = ConfigDict(extra="forbid")
output_version: Literal["V1"]
outcome: Literal[
"resolved",
"rebase-failed",
"noop",
"blocked",
"competence-failure",
]
files_touched: list[str] = Field(default_factory=list)
commit_shas: list[str] = Field(default_factory=list)
confidence: Literal["high", "medium", "low"]
blockers: list[str] = Field(default_factory=list)
used_tier: Literal[0, 1, 2]
wallclock_seconds: float = Field(..., ge=0.0)
class IssueImplementerOutputV1(BaseModel):
"""Implementer output for issue-kind workflows (which create new PRs)."""
model_config = ConfigDict(extra="forbid")
output_version: Literal["V1"]
outcome: Literal["created-pr", "noop", "blocked", "competence-failure"]
created_pr_number: int | None = Field(
None,
description="Required iff outcome='created-pr'.",
)
head_branch: str | None = Field(
None,
description="Required iff outcome='created-pr'; used for orphan-PR adoption.",
)
files_touched: list[str] = Field(default_factory=list)
commit_shas: list[str] = Field(default_factory=list)
confidence: Literal["high", "medium", "low"]
blockers: list[str] = Field(default_factory=list)
wallclock_seconds: float = Field(..., ge=0.0)
# ─── estimator ────────────────────────────────────────────────────────
class EstimatorInputV1(BaseModel):
"""Input to an estimator session (decides starting tier)."""
model_config = ConfigDict(extra="forbid")
input_version: Literal["V1"] = "V1"
workflow_id: int = Field(..., ge=0)
attempt_id: int = Field(..., ge=0)
pr_number: int | None = Field(None, ge=1)
head_sha: str | None = None
ci_summary: CISummary | None = None # None for issue workflows pre-PR
diff_summary: str | None = None
pr_title: str
pr_body: str = ""
workspace_dir: str
wallclock_budget_s: int = Field(..., ge=1)
class EstimatorOutputV1(BaseModel):
"""What the estimator emits."""
model_config = ConfigDict(extra="forbid")
output_version: Literal["V1"]
recommended_tier: Literal[0, 1, 2]
is_metadata_only: bool = Field(
False,
description=(
"v9: informational only; routes reviewer to lightweight profile. "
"Does NOT bypass REVIEWING (Hard Rule #1)."
),
)
confidence: Literal["high", "medium", "low"]
reasoning: str = Field(..., max_length=2048)
wallclock_seconds: float = Field(..., ge=0.0)
# ─── conflict resolver ────────────────────────────────────────────────
class ConflictedFile(BaseModel):
"""Per-file conflict descriptor."""
model_config = ConfigDict(extra="forbid")
file_path: str
ours_excerpt: str = Field(..., max_length=8192)
theirs_excerpt: str = Field(..., max_length=8192)
base_excerpt: str | None = Field(None, max_length=8192)
class ConflictResolverInputV1(BaseModel):
"""Input to a conflict-resolver session."""
model_config = ConfigDict(extra="forbid")
input_version: Literal["V1"] = "V1"
workflow_id: int = Field(..., ge=0)
attempt_id: int = Field(..., ge=0)
pr_number: int = Field(..., ge=1)
head_sha: str
base_branch: str
base_sha: str
conflicted_files: list[ConflictedFile]
prior_implementer_outputs: PriorAttemptsBlock
workspace_dir: str
wallclock_budget_s: int = Field(..., ge=1)
class ConflictResolverOutputV1(BaseModel):
"""What the conflict-resolver emits."""
model_config = ConfigDict(extra="forbid")
output_version: Literal["V1"]
outcome: Literal["resolved", "partial", "irreconcilable", "competence-failure"]
files_modified: list[str] = Field(default_factory=list)
commit_shas: list[str] = Field(default_factory=list)
new_head_sha: str | None = Field(
None,
description="Required iff outcome='resolved'.",
)
reasoning: str = Field(..., max_length=2048)
confidence: Literal["high", "medium", "low"]
wallclock_seconds: float = Field(..., ge=0.0)
# ─── summarizer ───────────────────────────────────────────────────────
class SummarizerInputV1(BaseModel):
"""Input to the summarizer (synthesizes older prior_attempts)."""
model_config = ConfigDict(extra="forbid")
input_version: Literal["V1"] = "V1"
workflow_id: int = Field(..., ge=0)
attempt_id: int = Field(..., ge=0)
prior_summary: str | None = Field(
None,
description=(
"Latest cached summary (covers attempts 1..K). None on "
"first summarization pass."
),
)
newly_aged_out_attempt: ImplementerOutputV1
workspace_dir: str
wallclock_budget_s: int = Field(..., ge=1)
class SummarizerOutputV1(BaseModel):
"""What the summarizer emits.
Length constraints are MCP-enforced too (set_summary refuses <50
or >2000 chars).
"""
model_config = ConfigDict(extra="forbid")
output_version: Literal["V1"]
summary: str = Field(..., min_length=50, max_length=2000)
covers_through_attempt: int = Field(..., ge=1)
wallclock_seconds: float = Field(..., ge=0.0)
# Resolve all forward refs.
PriorAttemptsBlock.model_rebuild()
ReviewerInputV1.model_rebuild()