From 6f713042b56022e064132c2c437200d028ddf5bf Mon Sep 17 00:00:00 2001 From: Chaim Date: Mon, 4 May 2026 17:23:48 +0000 Subject: [PATCH] =?UTF-8?q?feat(settings):=20add=20Agents=20tab=20?= =?UTF-8?q?=E2=80=94=20read-only=20Paperclip=20agent=20config=20view?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- .../app/settings/_components/agents-tab.tsx | 367 ++++++++++++++++++ web-ui/src/app/settings/page.tsx | 8 +- web-ui/src/lib/api/paperclip-agents.ts | 71 ++++ web/app.py | 139 +++++++ 4 files changed, 584 insertions(+), 1 deletion(-) create mode 100644 web-ui/src/app/settings/_components/agents-tab.tsx create mode 100644 web-ui/src/lib/api/paperclip-agents.ts diff --git a/web-ui/src/app/settings/_components/agents-tab.tsx b/web-ui/src/app/settings/_components/agents-tab.tsx new file mode 100644 index 0000000..e939cd6 --- /dev/null +++ b/web-ui/src/app/settings/_components/agents-tab.tsx @@ -0,0 +1,367 @@ +"use client"; + +import { useState } from "react"; +import { + AlertCircle, + Bot, + ChevronDown, + ChevronUp, + PauseCircle, + PlayCircle, +} from "lucide-react"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Card, CardContent } from "@/components/ui/card"; +import { Skeleton } from "@/components/ui/skeleton"; +import { + usePaperclipAgents, + type AgentPair, + type DriftEntry, + type PaperclipAgent, +} from "@/lib/api/paperclip-agents"; + +const ROLE_LABEL: Record = { + ceo: "CEO", + researcher: "מחקר", + engineer: "כתיבה", + qa: "בקרה", + general: "כללי", +}; + +const FIELD_LABEL: Record = { + model: "מודל", + effort: "effort", + timeoutSec: "timeout (שניות)", + maxTurnsPerRun: "max turns", + desiredSkills: "skills", + instructionsBundleMode: "bundle mode", + instructionsEntryFile: "entry file", + graceSec: "grace (שניות)", + cooldownSec: "cooldown (שניות)", + wakeOnDemand: "wake on demand", + maxConcurrentRuns: "max concurrent", + budget_monthly_cents: "תקציב חודשי", + status: "סטטוס", +}; + +function formatCents(cents: number | null): string { + if (cents == null) return "—"; + return `$${(cents / 100).toFixed(2)}`; +} + +function StatusBadge({ agent }: { agent: PaperclipAgent }) { + const status = agent.status ?? "unknown"; + if (status === "paused" || status === "terminated") { + return ( + + + {status === "paused" ? "מושהה" : "סיים"} + + ); + } + return ( + + + פעיל + + ); +} + +function FieldRow({ + label, + master, + mirror, + drifted, + mono, +}: { + label: string; + master: React.ReactNode; + mirror: React.ReactNode; + drifted: boolean; + mono?: boolean; +}) { + const cellBase = `tabular-nums text-[0.82rem] ${mono ? "font-mono" : ""}`; + const cellCls = (val: React.ReactNode) => + `${cellBase} px-2 py-1 rounded ${ + drifted ? "bg-warn-bg text-warn border border-warn/40" : "text-ink" + } ${val == null || val === "—" ? "text-ink-light" : ""}`; + return ( +
+
{label}
+
{master ?? "—"}
+
{mirror ?? "—"}
+
+ ); +} + +function PairCard({ pair }: { pair: AgentPair }) { + const [expanded, setExpanded] = useState(false); + const driftFields = new Set(pair.drift.map((d) => d.field)); + const driftCount = pair.drift.length; + const pairMissing = driftFields.has("_pair_missing"); + const a = pair.master ?? pair.mirror; + if (!a) return null; + + const fieldVal = ( + side: "master" | "mirror", + key: keyof PaperclipAgent, + ): React.ReactNode => { + const agent = pair[side]; + if (!agent) return ; + const v = agent[key]; + if (v == null) return "—"; + if (typeof v === "boolean") return v ? "✓" : "✗"; + if (Array.isArray(v)) return `${v.length}`; + return String(v); + }; + + const skillsList = (agent: PaperclipAgent | null) => + agent?.desiredSkills?.length ? agent.desiredSkills : []; + + return ( + + +
+
+ +
+

{pair.name}

+
+ + {ROLE_LABEL[pair.role ?? ""] ?? pair.role ?? "—"} + + {pair.master && } +
+
+
+ {pairMissing ? ( + + + {pair.master ? "חסר ב-CMPA" : "חסר ב-CMP"} + + ) : driftCount > 0 ? ( + + + {driftCount} פערים + + ) : ( + + מסונכרן + + )} +
+ +
+
+
CMP (1xxx)
+
CMPA (8xxx)
+
+ +
+ + + + + + + + + + + + +
+ +
+ + {pair.master?.updated_at && ( + + עודכן: {new Date(pair.master.updated_at).toLocaleDateString("he-IL")} + + )} +
+ + {expanded && ( +
+ {pair.drift.length > 0 && !pairMissing && ( +
+
פערי סנכרון
+
    + {pair.drift.map((d: DriftEntry) => ( +
  • + {FIELD_LABEL[d.field] ?? d.field} + CMP: + {JSON.stringify(d.master)} + CMPA: + {JSON.stringify(d.mirror)} +
  • + ))} +
+
+ )} +
+ {(["master", "mirror"] as const).map((side) => { + const agent = pair[side]; + const skills = skillsList(agent); + return ( +
+
+ {side === "master" ? "CMP" : "CMPA"} +
+ {agent ? ( + <> +
+ id: {agent.id} +
+
+
+ skills ({skills.length}) +
+ {skills.length === 0 ? ( + + ) : ( +
    + {skills.map((s) => ( +
  • + {s} +
  • + ))} +
+ )} +
+ {agent.instructionsFilePath && ( +
+
instructions path
+ + {agent.instructionsFilePath} + +
+ )} + {agent.pause_reason && ( +
+ סיבת השהיה: {agent.pause_reason} +
+ )} + + ) : ( + חסר + )} +
+ ); + })} +
+
+ )} +
+
+ ); +} + +export function AgentsTab() { + const { data, isPending, error, refetch, isFetching } = usePaperclipAgents(); + + if (error) { + return ( + + + + שגיאה: {error.message} + + + ); + } + + if (isPending) { + return ( +
+ {[...Array(7)].map((_, i) => ( + + ))} +
+ ); + } + + if (!data || data.pairs.length === 0) { + return ( + + + לא נמצאו סוכנים + + + ); + } + + const totalDrift = data.pairs.reduce( + (sum, p) => sum + p.drift.filter((d) => d.field !== "_pair_missing").length, + 0, + ); + const missingCount = data.pairs.filter((p) => !p.master || !p.mirror).length; + + return ( +
+ + +
+ {data.pairs.length} סוכנים × 2 חברות (CMP master / CMPA mirror) + {totalDrift > 0 && ( + + · {totalDrift} פערי סנכרון + + )} + {missingCount > 0 && ( + · {missingCount} זוגות לא שלמים + )} +
+ +
+
+
+ {data.pairs.map((pair) => ( + + ))} +
+
+ ); +} diff --git a/web-ui/src/app/settings/page.tsx b/web-ui/src/app/settings/page.tsx index 4731f8a..505d66e 100644 --- a/web-ui/src/app/settings/page.tsx +++ b/web-ui/src/app/settings/page.tsx @@ -1,7 +1,7 @@ "use client"; import Link from "next/link"; -import { Server, Wrench, Plug, Building2, Layers } from "lucide-react"; +import { Server, Wrench, Plug, Building2, Layers, Bot } from "lucide-react"; import { AppShell } from "@/components/app-shell"; import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; import { PaperclipTab } from "./_components/paperclip-tab"; @@ -9,6 +9,7 @@ import { EnvironmentTab } from "./_components/environment-tab"; import { ToolsTab } from "./_components/tools-tab"; import { RegistrationsTab } from "./_components/registrations-tab"; import { BlocksTab } from "./_components/blocks-tab"; +import { AgentsTab } from "./_components/agents-tab"; export default function SettingsPage() { return ( @@ -36,6 +37,10 @@ export default function SettingsPage() { Paperclip + + + סוכנים + סביבה @@ -55,6 +60,7 @@ export default function SettingsPage() { + diff --git a/web-ui/src/lib/api/paperclip-agents.ts b/web-ui/src/lib/api/paperclip-agents.ts new file mode 100644 index 0000000..b2f4b18 --- /dev/null +++ b/web-ui/src/lib/api/paperclip-agents.ts @@ -0,0 +1,71 @@ +/** + * Paperclip agents — read-only admin view (Task #29). + * + * Backend: `GET /api/admin/paperclip-agents` returns master+mirror pairs + * (CMP / CMPA) for all 7 agent roles, with drift detection between the pair. + */ + +import { useQuery } from "@tanstack/react-query"; +import { apiRequest } from "./client"; + +export type PaperclipAgent = { + id: string; + company_id: string; + company_name: string; + name: string; + role: string | null; + status: string | null; + pause_reason: string | null; + adapter_type: string | null; + model: string | null; + effort: string | null; + timeoutSec: number | null; + maxTurnsPerRun: number | null; + desiredSkills: string[]; + instructionsBundleMode: string | null; + instructionsRootPath: string | null; + instructionsEntryFile: string | null; + instructionsFilePath: string | null; + graceSec: number | null; + cooldownSec: number | null; + wakeOnDemand: boolean | null; + maxConcurrentRuns: number | null; + intervalSec: number | null; + enabled: boolean | null; + budget_monthly_cents: number | null; + spent_monthly_cents: number | null; + last_heartbeat_at: string | null; + updated_at: string | null; +}; + +export type DriftEntry = { + field: string; + master: unknown; + mirror: unknown; +}; + +export type AgentPair = { + name: string; + role: string | null; + master: PaperclipAgent | null; + mirror: PaperclipAgent | null; + drift: DriftEntry[]; +}; + +export type PaperclipAgentsResponse = { + pairs: AgentPair[]; + companies: Array<{ + id: string; + label: string; + slot: "master" | "mirror"; + }>; +}; + +export function usePaperclipAgents() { + return useQuery({ + queryKey: ["settings", "paperclip-agents"] as const, + queryFn: ({ signal }) => + apiRequest("/api/admin/paperclip-agents", { signal }), + staleTime: 30_000, + }); +} diff --git a/web/app.py b/web/app.py index 7d9c2f3..e0150bf 100644 --- a/web/app.py +++ b/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.