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:
139
web/app.py
139
web/app.py
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user