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.
157 lines
4.8 KiB
Python
157 lines
4.8 KiB
Python
#!/usr/bin/env python3
|
|
"""Estimator response-builder MCP server.
|
|
|
|
Spawned per-attempt. The estimator session calls these tools to
|
|
build EstimatorOutputV1. Lightweight; few fields.
|
|
"""
|
|
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 EstimatorOutputV1 # noqa: E402
|
|
from tools.controller.mcp._builder_base import ( # noqa: E402
|
|
BuilderError,
|
|
BuilderState,
|
|
call_with_invariants,
|
|
finalize_and_emit,
|
|
ok,
|
|
)
|
|
|
|
server = FastMCP("estimator-builder")
|
|
_STATE = BuilderState()
|
|
|
|
_TIER_VALUES = {0, 1, 2}
|
|
_CONFIDENCE_VALUES = {"high", "medium", "low"}
|
|
|
|
|
|
@server.tool()
|
|
def estimator_set_recommended_tier(tier: int) -> dict[str, Any]:
|
|
args = {"tier": tier}
|
|
|
|
def body() -> dict[str, Any]:
|
|
# Estimator doesn't have an explicit start tool — first setter
|
|
# implicitly starts the session.
|
|
_STATE.require_not_finalized()
|
|
if not _STATE.started:
|
|
_STATE.started = True
|
|
_STATE.started_at = datetime.now(timezone.utc)
|
|
if tier not in _TIER_VALUES:
|
|
raise BuilderError(
|
|
f"invalid tier {tier!r}; expected one of {sorted(_TIER_VALUES)}"
|
|
)
|
|
_STATE.fields["recommended_tier"] = tier
|
|
return ok(tier=tier)
|
|
|
|
return call_with_invariants(_STATE, "estimator_set_recommended_tier", body, args)
|
|
|
|
|
|
@server.tool()
|
|
def estimator_set_is_metadata_only(value: bool) -> dict[str, Any]:
|
|
args = {"value": value}
|
|
|
|
def body() -> dict[str, Any]:
|
|
_STATE.require_not_finalized()
|
|
if not _STATE.started:
|
|
_STATE.started = True
|
|
_STATE.started_at = datetime.now(timezone.utc)
|
|
_STATE.fields["is_metadata_only"] = bool(value)
|
|
return ok(is_metadata_only=bool(value))
|
|
|
|
return call_with_invariants(_STATE, "estimator_set_is_metadata_only", body, args)
|
|
|
|
|
|
@server.tool()
|
|
def estimator_set_confidence(confidence: str) -> dict[str, Any]:
|
|
args = {"confidence": confidence}
|
|
|
|
def body() -> dict[str, Any]:
|
|
_STATE.require_not_finalized()
|
|
if not _STATE.started:
|
|
_STATE.started = True
|
|
_STATE.started_at = datetime.now(timezone.utc)
|
|
if confidence not in _CONFIDENCE_VALUES:
|
|
raise BuilderError(
|
|
f"invalid confidence {confidence!r}; expected one of "
|
|
f"{sorted(_CONFIDENCE_VALUES)}"
|
|
)
|
|
_STATE.fields["confidence"] = confidence
|
|
return ok(confidence=confidence)
|
|
|
|
return call_with_invariants(_STATE, "estimator_set_confidence", body, args)
|
|
|
|
|
|
@server.tool()
|
|
def estimator_set_reasoning(text: str) -> dict[str, Any]:
|
|
args = {"text_len": len(text)}
|
|
|
|
def body() -> dict[str, Any]:
|
|
_STATE.require_not_finalized()
|
|
if not _STATE.started:
|
|
_STATE.started = True
|
|
_STATE.started_at = datetime.now(timezone.utc)
|
|
if not text.strip():
|
|
raise BuilderError("reasoning must be non-empty")
|
|
if len(text) > 2048:
|
|
raise BuilderError(
|
|
f"reasoning too long ({len(text)} chars); cap is 2048"
|
|
)
|
|
_STATE.fields["reasoning"] = text
|
|
return ok(reasoning_len=len(text))
|
|
|
|
return call_with_invariants(_STATE, "estimator_set_reasoning", body, args)
|
|
|
|
|
|
@server.tool()
|
|
def estimator_finalize() -> dict[str, Any]:
|
|
def body() -> dict[str, Any]:
|
|
_STATE.require_started()
|
|
_STATE.require_not_finalized()
|
|
missing = [
|
|
f for f in ("recommended_tier", "confidence", "reasoning")
|
|
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.setdefault("is_metadata_only", False)
|
|
_STATE.fields["wallclock_seconds"] = wallclock
|
|
return finalize_and_emit(_STATE, EstimatorOutputV1)
|
|
|
|
_STATE.record("estimator_finalize", {})
|
|
try:
|
|
return body()
|
|
except BuilderError as e:
|
|
return {"error": str(e), "tool": "estimator_finalize"}
|
|
|
|
|
|
@server.tool()
|
|
def estimator_state() -> dict[str, Any]:
|
|
return {
|
|
"started": _STATE.started,
|
|
"finalized": _STATE.finalized,
|
|
"fields_set": sorted(_STATE.fields.keys()),
|
|
"audit_entries": len(_STATE.audit),
|
|
}
|
|
|
|
|
|
main = make_main(server, "estimator-builder")
|
|
|
|
|
|
if __name__ == "__main__":
|
|
raise SystemExit(main())
|