Files
cleveragents-core/tools/controller/mcp/summarizer_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

122 lines
3.7 KiB
Python

#!/usr/bin/env python3
"""Summarizer response-builder MCP server.
Spawned per-summarization-attempt (one per "implementer attempt aged
out of verbatim window"). The summarizer reads the prior summary +
the newly-aged-out implementer attempt, produces an updated
1-paragraph synthesis.
Lightweight; one main setter + finalize.
"""
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 SummarizerOutputV1 # noqa: E402
from tools.controller.mcp._builder_base import ( # noqa: E402
BuilderError,
BuilderState,
call_with_invariants,
finalize_and_emit,
ok,
)
server = FastMCP("summarizer-builder")
_STATE = BuilderState()
@server.tool()
def summarizer_set_summary(text: str) -> dict[str, Any]:
"""Set the synthesis text. 50-2000 chars (mirrors SummarizerOutputV1)."""
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 50 <= len(text) <= 2000:
raise BuilderError(
f"summary must be 50-2000 chars; got {len(text)}"
)
_STATE.fields["summary"] = text
return ok(summary_len=len(text))
return call_with_invariants(_STATE, "summarizer_set_summary", body, args)
@server.tool()
def summarizer_set_covers_through_attempt(attempt_number: int) -> dict[str, Any]:
"""Record which prior attempt number this summary's coverage ends at."""
args = {"attempt_number": attempt_number}
def body() -> dict[str, Any]:
_STATE.require_not_finalized()
if not _STATE.started:
_STATE.started = True
_STATE.started_at = datetime.now(timezone.utc)
if attempt_number < 1:
raise BuilderError(
f"covers_through_attempt must be ≥1; got {attempt_number}"
)
_STATE.fields["covers_through_attempt"] = attempt_number
return ok(covers_through_attempt=attempt_number)
return call_with_invariants(
_STATE, "summarizer_set_covers_through_attempt", body, args
)
@server.tool()
def summarizer_finalize() -> dict[str, Any]:
def body() -> dict[str, Any]:
_STATE.require_started()
_STATE.require_not_finalized()
missing = [
f for f in ("summary", "covers_through_attempt")
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
return finalize_and_emit(_STATE, SummarizerOutputV1)
_STATE.record("summarizer_finalize", {})
try:
return body()
except BuilderError as e:
return {"error": str(e), "tool": "summarizer_finalize"}
@server.tool()
def summarizer_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, "summarizer-builder")
if __name__ == "__main__":
raise SystemExit(main())