0bc734c020
Applies `ruff format` to the accumulated formatting debt on this branch. Formatting-only — no behavioral changes. Required for CI/lint's format gate (`nox -s format -- --check`), which the branch was failing on 288 tracked files that drifted from ruff's canonical style. In-progress WIP files are intentionally excluded so this commit stays a clean formatting-only diff. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
156 lines
4.8 KiB
Python
156 lines
4.8 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_start(
|
|
workflow_id: int,
|
|
attempt_id: int,
|
|
) -> dict[str, Any]:
|
|
"""Initialize the summarizer session. RECOMMENDED first call.
|
|
|
|
Setters auto-start without this for backward compat, but calling
|
|
this explicitly is the contract the prompt advertises + lets the
|
|
builder detect cross-session reuse."""
|
|
args = {"workflow_id": workflow_id, "attempt_id": attempt_id}
|
|
|
|
def body() -> dict[str, Any]:
|
|
# Unconditional reset on _start — see implementer_builder.py.
|
|
if _STATE.started:
|
|
_STATE.reset_for_new_attempt()
|
|
_STATE.started = True
|
|
_STATE.started_at = datetime.now(timezone.utc)
|
|
_STATE.identity = {
|
|
"workflow_id": workflow_id,
|
|
"attempt_id": attempt_id,
|
|
}
|
|
return ok()
|
|
|
|
return call_with_invariants(_STATE, "summarizer_start", body, args)
|
|
|
|
|
|
@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(output_path: str | None = None) -> dict[str, Any]:
|
|
"""Validate + emit SummarizerOutputV1 JSON to ``output_path``
|
|
(per-attempt path from the controller's prompt)."""
|
|
|
|
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,
|
|
output_path=output_path,
|
|
)
|
|
|
|
_STATE.record("summarizer_finalize", {"output_path": output_path})
|
|
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())
|