Files
cleveragents-core/tools/controller/mcp/implementer_builder.py
T
drew eebb5718a8 feat(controller): Phase 1a — 5 response-builder MCP servers
Per-attempt MCP subprocesses that enforce V1 contract invariants at
construction time. Worker LLM calls builder tools incrementally; the
MCP validates each call against the schema + cross-field invariants;
`{role}_finalize()` emits canonical Pydantic-validated JSON to stdout
for the worker controller to read (Phase 1c). Defense-in-depth: the
controller strict-parses whatever finalize emits.

Builders shipped:

- reviewer_builder: 9 tools. Auto-acks all CISummary gates as passed
  at start; reviewer only calls record_gate to discuss specifics.
  reviewer_override_gate requires ≥20-char justification. Approve
  with any failed gate is refused with an actionable error pointing
  at the override path. Request-changes requires ≥1 blocking issue.
  Verdict-vs-blocking-issues invariant checked at finalize.

- implementer_builder: 7 tools. Outcome-specific finalize invariants:
  resolved → ≥1 commit + ≥1 file; blocked → ≥1 blocker; noop → no
  commits/files/blockers.

- estimator_builder: 4 tools. Lightweight; requires
  recommended_tier + confidence + reasoning at finalize. Reasoning
  capped at 2048 chars.

- conflict_resolver_builder: 8 tools. outcome='resolved' requires
  new_head_sha + ≥1 commit + ≥1 file. resolution_strategy enum-checked.

- summarizer_builder: 3 tools. Summary 50-2000 chars (enforced at
  MCP layer and Pydantic).

Shared infrastructure:

- _builder_base.py: BuilderState dataclass + invariant guard helpers
  (require_started / require_not_finalized) + audit-record-with-summary
  + finalize_and_emit (validates against Pydantic model class,
  emits canonical JSON to stdout, marks finalized).

41 builder tests in test_mcp_builders.py (happy paths + every
invariant + outcome-specific paths + audit summarization + JSON
round-trip through Pydantic strict-parse). Plus the existing 62
Phase-0 tests. 103 controller tests total. Full auto_agents suite
(2465 tests) still passes.
2026-05-18 11:09:40 -04:00

250 lines
8.9 KiB
Python

#!/usr/bin/env python3
"""Implementer response-builder MCP server.
Spawned per-attempt by the worker controller. Validates each builder
call against ImplementerOutputV1 and enforces outcome-specific
invariants at finalize:
- outcome='resolved' → requires ≥1 commit AND ≥1 file modified
- outcome='rebase-failed' → no extra requirements; controller routes
to CONFLICT_RESOLVING
- outcome='blocked' → requires ≥1 entry in blockers
- outcome='noop' → forbids commits + files + blockers
- outcome='competence-failure' → no extra requirements
"""
from __future__ import annotations
import sys
from datetime import datetime, timezone
from pathlib import Path
from typing import Any
from mcp.server.fastmcp import FastMCP
_REPO_ROOT = Path(__file__).resolve().parents[3]
if str(_REPO_ROOT) not in sys.path:
sys.path.insert(0, str(_REPO_ROOT))
from tools._mcp_common import make_main # noqa: E402
from tools.controller.contracts.v1 import ImplementerOutputV1 # noqa: E402
from tools.controller.mcp._builder_base import ( # noqa: E402
BuilderError,
BuilderState,
call_with_invariants,
finalize_and_emit,
ok,
)
server = FastMCP("implementer-builder")
_STATE = BuilderState()
_OUTCOME_VALUES = {
"resolved", "rebase-failed", "noop", "blocked", "competence-failure"
}
_CONFIDENCE_VALUES = {"high", "medium", "low"}
_TIER_VALUES = {0, 1, 2}
@server.tool()
def implementer_start(
workflow_id: int, attempt_id: int, pr_number: int, tier: int
) -> dict[str, Any]:
"""Initialize the implementer session. MUST be the first tool called."""
args = {"workflow_id": workflow_id, "attempt_id": attempt_id,
"pr_number": pr_number, "tier": tier}
def body() -> dict[str, Any]:
if _STATE.started:
raise BuilderError("implementer_start may only be called once per attempt")
if tier not in _TIER_VALUES:
raise BuilderError(f"invalid tier {tier!r}; expected one of {sorted(_TIER_VALUES)}")
_STATE.started = True
_STATE.started_at = datetime.now(timezone.utc)
_STATE.identity = {
"workflow_id": workflow_id, "attempt_id": attempt_id,
"pr_number": pr_number, "tier": tier,
}
_STATE.fields["used_tier"] = tier
return ok(tier=tier)
return call_with_invariants(_STATE, "implementer_start", body, args)
@server.tool()
def implementer_record_file_modified(
path: str, lines_added: int = 0, lines_deleted: int = 0
) -> dict[str, Any]:
"""Record one file the implementer modified."""
args = {"path": path, "lines_added": lines_added, "lines_deleted": lines_deleted}
def body() -> dict[str, Any]:
_STATE.require_started()
_STATE.require_not_finalized()
if not path.strip():
raise BuilderError("path must be non-empty")
if lines_added < 0 or lines_deleted < 0:
raise BuilderError("lines_added/lines_deleted must be ≥ 0")
files = _STATE.fields.setdefault("files_touched", [])
if path not in files:
files.append(path)
return ok(files_touched_count=len(files))
return call_with_invariants(_STATE, "implementer_record_file_modified", body, args)
@server.tool()
def implementer_record_commit(sha: str, message: str) -> dict[str, Any]:
"""Record a git commit the implementer pushed."""
args = {"sha": sha, "message_len": len(message)}
def body() -> dict[str, Any]:
_STATE.require_started()
_STATE.require_not_finalized()
if not sha.strip() or len(sha) < 7:
raise BuilderError(f"sha must look like a git SHA (≥7 chars); got {sha!r}")
if not message.strip():
raise BuilderError("commit message must be non-empty")
commits = _STATE.fields.setdefault("commit_shas", [])
if sha not in commits:
commits.append(sha)
return ok(commits_count=len(commits))
return call_with_invariants(_STATE, "implementer_record_commit", body, args)
@server.tool()
def implementer_add_blocker(description: str) -> dict[str, Any]:
"""Add a blocker explanation. Only valid when outcome will be 'blocked'."""
args = {"description": description}
def body() -> dict[str, Any]:
_STATE.require_started()
_STATE.require_not_finalized()
if not description.strip():
raise BuilderError("blocker description must be non-empty")
current_outcome = _STATE.fields.get("outcome")
if current_outcome and current_outcome != "blocked":
raise BuilderError(
f"cannot add blocker with outcome={current_outcome!r}; "
"set outcome='blocked' first"
)
blockers = _STATE.fields.setdefault("blockers", [])
blockers.append(description)
return ok(blockers_count=len(blockers))
return call_with_invariants(_STATE, "implementer_add_blocker", body, args)
@server.tool()
def implementer_set_outcome(outcome: str) -> dict[str, Any]:
args = {"outcome": outcome}
def body() -> dict[str, Any]:
_STATE.require_started()
_STATE.require_not_finalized()
if outcome not in _OUTCOME_VALUES:
raise BuilderError(
f"invalid outcome {outcome!r}; expected one of {sorted(_OUTCOME_VALUES)}"
)
_STATE.fields["outcome"] = outcome
return ok(outcome=outcome)
return call_with_invariants(_STATE, "implementer_set_outcome", body, args)
@server.tool()
def implementer_set_confidence(confidence: str) -> dict[str, Any]:
args = {"confidence": confidence}
def body() -> dict[str, Any]:
_STATE.require_started()
_STATE.require_not_finalized()
if confidence not in _CONFIDENCE_VALUES:
raise BuilderError(
f"invalid confidence {confidence!r}; expected one of {sorted(_CONFIDENCE_VALUES)}"
)
_STATE.fields["confidence"] = confidence
return ok(confidence=confidence)
return call_with_invariants(_STATE, "implementer_set_confidence", body, args)
def _check_outcome_invariants(state: BuilderState) -> None:
"""Outcome-specific finalize check."""
outcome = state.fields.get("outcome")
files = state.fields.get("files_touched") or []
commits = state.fields.get("commit_shas") or []
blockers = state.fields.get("blockers") or []
if outcome == "resolved":
if not commits:
raise BuilderError("outcome='resolved' requires ≥1 commit; "
"call implementer_record_commit at least once")
if not files:
raise BuilderError("outcome='resolved' requires ≥1 file modified; "
"call implementer_record_file_modified at least once")
elif outcome == "blocked":
if not blockers:
raise BuilderError("outcome='blocked' requires ≥1 blocker; "
"call implementer_add_blocker at least once")
elif outcome == "noop":
if commits or files or blockers:
raise BuilderError(
"outcome='noop' forbids commits/files/blockers; "
f"got {len(commits)} commits, {len(files)} files, "
f"{len(blockers)} blockers"
)
@server.tool()
def implementer_finalize() -> dict[str, Any]:
def body() -> dict[str, Any]:
_STATE.require_started()
_STATE.require_not_finalized()
missing = [
f for f in ("outcome", "confidence", "used_tier")
if f not in _STATE.fields
]
if missing:
raise BuilderError(
f"missing required fields: {missing}. Call the matching "
"setters before finalize."
)
started = _STATE.started_at
wallclock = (datetime.now(timezone.utc) - started).total_seconds() if started else 0.0
_STATE.fields["output_version"] = "V1"
_STATE.fields["wallclock_seconds"] = wallclock
_STATE.fields.setdefault("files_touched", [])
_STATE.fields.setdefault("commit_shas", [])
_STATE.fields.setdefault("blockers", [])
return finalize_and_emit(
_STATE, ImplementerOutputV1,
extra_required_check=_check_outcome_invariants,
)
_STATE.record("implementer_finalize", {})
try:
return body()
except BuilderError as e:
return {"error": str(e), "tool": "implementer_finalize"}
@server.tool()
def implementer_state() -> dict[str, Any]:
return {
"started": _STATE.started,
"finalized": _STATE.finalized,
"identity": dict(_STATE.identity),
"fields_set": sorted(_STATE.fields.keys()),
"files_count": len(_STATE.fields.get("files_touched") or []),
"commits_count": len(_STATE.fields.get("commit_shas") or []),
"blockers_count": len(_STATE.fields.get("blockers") or []),
"audit_entries": len(_STATE.audit),
}
main = make_main(server, "implementer-builder")
if __name__ == "__main__":
raise SystemExit(main())