feat(settings): add Agents tab — read-only Paperclip agent config view

Task #29: surfaces all 14 agents (7 roles × 2 companies) in /settings as
master+mirror pairs with drift detection. Replaces ad-hoc psql + script
inspection with a single dashboard.

Backend: GET /api/admin/paperclip-agents — fetches via Paperclip API
(not direct DB), groups by name, computes drift across model/effort/
timeoutSec/maxTurnsPerRun/skills/runtime_config.heartbeat/budget/status.

Frontend: new AgentsTab card-per-pair with side-by-side compare,
drift highlighting, expandable details (skills list + instructions path).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-04 17:23:48 +00:00
parent d0994704cf
commit 6f713042b5
4 changed files with 584 additions and 1 deletions

View File

@@ -44,7 +44,9 @@ from web.mcp_env_catalog import (
normalize_for_compare,
)
from web.progress_store import ProgressStore
from web.paperclip_api import pc_request
from web.paperclip_client import (
COMPANIES as PAPERCLIP_COMPANIES,
accept_interaction as pc_accept_interaction,
archive_project as pc_archive_project,
create_project as pc_create_project,
@@ -3172,6 +3174,143 @@ async def api_list_skills():
return skills
# ---------------------------------------------------------------------------
# Paperclip agents — read-only admin view (Task #29)
# ---------------------------------------------------------------------------
# Display order for the 7 agent roles (master+mirror pairs grouped by name).
# Matches the legal pipeline: CEO → analysis → research → writing → QA → export → proof.
_AGENT_NAME_ORDER = {
"עוזר משפטי": 1,
"מנתח משפטי": 2,
"חוקר תקדימים": 3,
"כותב החלטה": 4,
"בודק איכות": 5,
"מייצא טיוטה": 6,
"הגהת מסמכים": 7,
}
# Fields that should match between master (CMP) and mirror (CMPA). Drift = bug.
_DRIFT_FIELDS = (
"model",
"effort",
"timeoutSec",
"maxTurnsPerRun",
"desiredSkills",
"instructionsBundleMode",
"instructionsEntryFile",
"graceSec",
"cooldownSec",
"wakeOnDemand",
"maxConcurrentRuns",
"budget_monthly_cents",
"status",
)
def _shape_paperclip_agent(raw: dict, company_id: str, company_name: str) -> dict:
"""Flatten a Paperclip agent row into the shape the UI consumes."""
ac = raw.get("adapterConfig") or {}
rc = raw.get("runtimeConfig") or {}
hb = rc.get("heartbeat") or {}
skill_sync = ac.get("paperclipSkillSync") or {}
return {
"id": raw.get("id"),
"company_id": company_id,
"company_name": company_name,
"name": raw.get("name"),
"role": raw.get("role"),
"status": raw.get("status"),
"pause_reason": raw.get("pauseReason"),
"adapter_type": raw.get("adapterType"),
"model": ac.get("model"),
"effort": ac.get("effort"),
"timeoutSec": ac.get("timeoutSec"),
"maxTurnsPerRun": ac.get("maxTurnsPerRun"),
"desiredSkills": sorted(skill_sync.get("desiredSkills") or []),
"instructionsBundleMode": ac.get("instructionsBundleMode"),
"instructionsRootPath": ac.get("instructionsRootPath"),
"instructionsEntryFile": ac.get("instructionsEntryFile"),
"instructionsFilePath": ac.get("instructionsFilePath"),
"graceSec": hb.get("graceSec"),
"cooldownSec": hb.get("cooldownSec"),
"wakeOnDemand": hb.get("wakeOnDemand"),
"maxConcurrentRuns": hb.get("maxConcurrentRuns"),
"intervalSec": hb.get("intervalSec"),
"enabled": hb.get("enabled"),
"budget_monthly_cents": raw.get("budgetMonthlyCents"),
"spent_monthly_cents": raw.get("spentMonthlyCents"),
"last_heartbeat_at": raw.get("lastHeartbeatAt"),
"updated_at": raw.get("updatedAt"),
}
def _compute_drift(master: dict | None, mirror: dict | None) -> list[dict]:
if master is None or mirror is None:
return [{"field": "_pair_missing", "master": master is not None, "mirror": mirror is not None}]
drift = []
for field in _DRIFT_FIELDS:
m_val = master.get(field)
i_val = mirror.get(field)
if m_val != i_val:
drift.append({"field": field, "master": m_val, "mirror": i_val})
return drift
@app.get("/api/admin/paperclip-agents")
async def api_list_paperclip_agents():
"""List all Paperclip agents grouped into master+mirror pairs with drift detection.
Read-only. Source of truth: Paperclip ``GET /api/companies/{id}/agents`` API
(not direct DB) — keeps us decoupled from Paperclip's schema changes.
"""
company_labels = {
PAPERCLIP_COMPANIES["licensing"]: "CMP — רישוי ובניה",
PAPERCLIP_COMPANIES["betterment"]: "CMPA — היטלי השבחה",
}
by_name: dict[str, dict[str, dict]] = {}
for cid, cname in company_labels.items():
try:
resp = await pc_request("GET", f"/api/companies/{cid}/agents", raise_on_error=True)
except (httpx.HTTPError, RuntimeError) as e:
logger.exception("Paperclip API failed for company %s", cid)
raise HTTPException(
status_code=503,
detail=f"Paperclip API error for company {cname}: {type(e).__name__}: {e}",
) from e
rows = resp.json()
if not isinstance(rows, list):
raise HTTPException(status_code=502, detail=f"Unexpected Paperclip response for {cname}")
for raw in rows:
shaped = _shape_paperclip_agent(raw, cid, cname)
slot = "master" if cid == PAPERCLIP_COMPANIES["licensing"] else "mirror"
by_name.setdefault(shaped["name"], {})[slot] = shaped
pairs = []
for name, group in by_name.items():
master = group.get("master")
mirror = group.get("mirror")
primary = master or mirror
pairs.append({
"name": name,
"role": primary.get("role") if primary else None,
"master": master,
"mirror": mirror,
"drift": _compute_drift(master, mirror),
})
pairs.sort(key=lambda p: (_AGENT_NAME_ORDER.get(p["name"], 99), p["name"]))
return {
"pairs": pairs,
"companies": [
{"id": cid, "label": label, "slot": "master" if cid == PAPERCLIP_COMPANIES["licensing"] else "mirror"}
for cid, label in company_labels.items()
],
}
@app.post("/api/admin/skills/install")
async def api_install_skill(file: UploadFile = File(...)):
"""Install or update a Paperclip skill from a ZIP file.