eebb5718a8
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.
250 lines
8.9 KiB
Python
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())
|