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:
367
web-ui/src/app/settings/_components/agents-tab.tsx
Normal file
367
web-ui/src/app/settings/_components/agents-tab.tsx
Normal file
@@ -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<string, string> = {
|
||||||
|
ceo: "CEO",
|
||||||
|
researcher: "מחקר",
|
||||||
|
engineer: "כתיבה",
|
||||||
|
qa: "בקרה",
|
||||||
|
general: "כללי",
|
||||||
|
};
|
||||||
|
|
||||||
|
const FIELD_LABEL: Record<string, string> = {
|
||||||
|
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 (
|
||||||
|
<Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">
|
||||||
|
<PauseCircle className="w-3 h-3 me-1" />
|
||||||
|
{status === "paused" ? "מושהה" : "סיים"}
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return (
|
||||||
|
<Badge variant="outline" className="bg-success-bg text-success border-success/40">
|
||||||
|
<PlayCircle className="w-3 h-3 me-1" />
|
||||||
|
פעיל
|
||||||
|
</Badge>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="grid grid-cols-[7rem_1fr_1fr] gap-2 items-center">
|
||||||
|
<div className="text-[0.75rem] text-ink-muted">{label}</div>
|
||||||
|
<div className={cellCls(master)} dir="ltr">{master ?? "—"}</div>
|
||||||
|
<div className={cellCls(mirror)} dir="ltr">{mirror ?? "—"}</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 <span className="text-ink-light">—</span>;
|
||||||
|
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 (
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4 space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||||
|
<div className="flex items-center gap-2 min-w-0">
|
||||||
|
<Bot className="w-5 h-5 text-gold-deep shrink-0" />
|
||||||
|
<div>
|
||||||
|
<h3 className="text-navy font-semibold text-base mb-0">{pair.name}</h3>
|
||||||
|
<div className="flex items-center gap-2 mt-0.5">
|
||||||
|
<Badge variant="outline" className="text-[0.7rem]">
|
||||||
|
{ROLE_LABEL[pair.role ?? ""] ?? pair.role ?? "—"}
|
||||||
|
</Badge>
|
||||||
|
{pair.master && <StatusBadge agent={pair.master} />}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{pairMissing ? (
|
||||||
|
<Badge variant="outline" className="bg-danger-bg text-danger border-danger/40">
|
||||||
|
<AlertCircle className="w-3 h-3 me-1" />
|
||||||
|
{pair.master ? "חסר ב-CMPA" : "חסר ב-CMP"}
|
||||||
|
</Badge>
|
||||||
|
) : driftCount > 0 ? (
|
||||||
|
<Badge variant="outline" className="bg-warn-bg text-warn border-warn/40">
|
||||||
|
<AlertCircle className="w-3 h-3 me-1" />
|
||||||
|
{driftCount} פערים
|
||||||
|
</Badge>
|
||||||
|
) : (
|
||||||
|
<Badge variant="outline" className="bg-success-bg text-success border-success/40">
|
||||||
|
מסונכרן
|
||||||
|
</Badge>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-[7rem_1fr_1fr] gap-2 text-[0.7rem] uppercase tracking-wide text-ink-muted border-b border-rule pb-1">
|
||||||
|
<div></div>
|
||||||
|
<div>CMP (1xxx)</div>
|
||||||
|
<div>CMPA (8xxx)</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-1">
|
||||||
|
<FieldRow label={FIELD_LABEL.model} master={fieldVal("master", "model")} mirror={fieldVal("mirror", "model")} drifted={driftFields.has("model")} mono />
|
||||||
|
<FieldRow label={FIELD_LABEL.effort} master={fieldVal("master", "effort")} mirror={fieldVal("mirror", "effort")} drifted={driftFields.has("effort")} />
|
||||||
|
<FieldRow label={FIELD_LABEL.timeoutSec} master={fieldVal("master", "timeoutSec")} mirror={fieldVal("mirror", "timeoutSec")} drifted={driftFields.has("timeoutSec")} />
|
||||||
|
<FieldRow label={FIELD_LABEL.maxTurnsPerRun} master={fieldVal("master", "maxTurnsPerRun")} mirror={fieldVal("mirror", "maxTurnsPerRun")} drifted={driftFields.has("maxTurnsPerRun")} />
|
||||||
|
<FieldRow
|
||||||
|
label={FIELD_LABEL.desiredSkills}
|
||||||
|
master={pair.master ? `${pair.master.desiredSkills.length}` : "—"}
|
||||||
|
mirror={pair.mirror ? `${pair.mirror.desiredSkills.length}` : "—"}
|
||||||
|
drifted={driftFields.has("desiredSkills")}
|
||||||
|
/>
|
||||||
|
<FieldRow label={FIELD_LABEL.graceSec} master={fieldVal("master", "graceSec")} mirror={fieldVal("mirror", "graceSec")} drifted={driftFields.has("graceSec")} />
|
||||||
|
<FieldRow label={FIELD_LABEL.cooldownSec} master={fieldVal("master", "cooldownSec")} mirror={fieldVal("mirror", "cooldownSec")} drifted={driftFields.has("cooldownSec")} />
|
||||||
|
<FieldRow label={FIELD_LABEL.wakeOnDemand} master={fieldVal("master", "wakeOnDemand")} mirror={fieldVal("mirror", "wakeOnDemand")} drifted={driftFields.has("wakeOnDemand")} />
|
||||||
|
<FieldRow label={FIELD_LABEL.maxConcurrentRuns} master={fieldVal("master", "maxConcurrentRuns")} mirror={fieldVal("mirror", "maxConcurrentRuns")} drifted={driftFields.has("maxConcurrentRuns")} />
|
||||||
|
<FieldRow
|
||||||
|
label={FIELD_LABEL.budget_monthly_cents}
|
||||||
|
master={
|
||||||
|
pair.master
|
||||||
|
? `${formatCents(pair.master.spent_monthly_cents)} / ${formatCents(pair.master.budget_monthly_cents)}`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
mirror={
|
||||||
|
pair.mirror
|
||||||
|
? `${formatCents(pair.mirror.spent_monthly_cents)} / ${formatCents(pair.mirror.budget_monthly_cents)}`
|
||||||
|
: "—"
|
||||||
|
}
|
||||||
|
drifted={driftFields.has("budget_monthly_cents")}
|
||||||
|
/>
|
||||||
|
<FieldRow label={FIELD_LABEL.instructionsBundleMode} master={fieldVal("master", "instructionsBundleMode")} mirror={fieldVal("mirror", "instructionsBundleMode")} drifted={driftFields.has("instructionsBundleMode")} mono />
|
||||||
|
<FieldRow label={FIELD_LABEL.instructionsEntryFile} master={fieldVal("master", "instructionsEntryFile")} mirror={fieldVal("mirror", "instructionsEntryFile")} drifted={driftFields.has("instructionsEntryFile")} mono />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center justify-between pt-1 border-t border-rule">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
className="text-[0.78rem] text-ink-muted"
|
||||||
|
onClick={() => setExpanded((v) => !v)}
|
||||||
|
>
|
||||||
|
{expanded ? (
|
||||||
|
<>
|
||||||
|
<ChevronUp className="w-3 h-3 me-1" />
|
||||||
|
כיווץ
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<ChevronDown className="w-3 h-3 me-1" />
|
||||||
|
פרטים מלאים
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
{pair.master?.updated_at && (
|
||||||
|
<span className="text-[0.7rem] text-ink-light">
|
||||||
|
עודכן: {new Date(pair.master.updated_at).toLocaleDateString("he-IL")}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{expanded && (
|
||||||
|
<div className="pt-2 border-t border-rule space-y-3">
|
||||||
|
{pair.drift.length > 0 && !pairMissing && (
|
||||||
|
<div className="rounded-md bg-warn-bg/40 border border-warn/30 p-3">
|
||||||
|
<div className="text-[0.78rem] text-warn font-medium mb-2">פערי סנכרון</div>
|
||||||
|
<ul className="space-y-1 text-[0.78rem]">
|
||||||
|
{pair.drift.map((d: DriftEntry) => (
|
||||||
|
<li key={d.field} className="flex items-center gap-2 flex-wrap">
|
||||||
|
<code dir="ltr" className="text-[0.72rem]">{FIELD_LABEL[d.field] ?? d.field}</code>
|
||||||
|
<span className="text-ink-muted">CMP:</span>
|
||||||
|
<code dir="ltr" className="text-[0.72rem] text-ink">{JSON.stringify(d.master)}</code>
|
||||||
|
<span className="text-ink-muted">CMPA:</span>
|
||||||
|
<code dir="ltr" className="text-[0.72rem] text-ink">{JSON.stringify(d.mirror)}</code>
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
||||||
|
{(["master", "mirror"] as const).map((side) => {
|
||||||
|
const agent = pair[side];
|
||||||
|
const skills = skillsList(agent);
|
||||||
|
return (
|
||||||
|
<div key={side} className="rounded-md border border-rule p-3 space-y-2">
|
||||||
|
<div className="text-[0.75rem] text-ink-muted">
|
||||||
|
{side === "master" ? "CMP" : "CMPA"}
|
||||||
|
</div>
|
||||||
|
{agent ? (
|
||||||
|
<>
|
||||||
|
<div className="text-[0.72rem] font-mono text-ink-muted" dir="ltr">
|
||||||
|
id: {agent.id}
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted mb-1">
|
||||||
|
skills ({skills.length})
|
||||||
|
</div>
|
||||||
|
{skills.length === 0 ? (
|
||||||
|
<span className="text-[0.78rem] text-ink-light">—</span>
|
||||||
|
) : (
|
||||||
|
<ul className="space-y-0.5">
|
||||||
|
{skills.map((s) => (
|
||||||
|
<li key={s} className="text-[0.72rem] font-mono" dir="ltr">
|
||||||
|
{s}
|
||||||
|
</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{agent.instructionsFilePath && (
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.72rem] text-ink-muted">instructions path</div>
|
||||||
|
<code className="text-[0.72rem] font-mono break-all" dir="ltr">
|
||||||
|
{agent.instructionsFilePath}
|
||||||
|
</code>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{agent.pause_reason && (
|
||||||
|
<div className="text-[0.78rem] text-warn">
|
||||||
|
סיבת השהיה: {agent.pause_reason}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<span className="text-[0.78rem] text-ink-light">חסר</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function AgentsTab() {
|
||||||
|
const { data, isPending, error, refetch, isFetching } = usePaperclipAgents();
|
||||||
|
|
||||||
|
if (error) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-danger/40">
|
||||||
|
<CardContent className="p-6 flex items-center gap-3 text-danger">
|
||||||
|
<AlertCircle className="w-5 h-5" />
|
||||||
|
<span>שגיאה: {error.message}</span>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (isPending) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-3">
|
||||||
|
{[...Array(7)].map((_, i) => (
|
||||||
|
<Skeleton key={i} className="h-48 w-full rounded-lg" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data || data.pairs.length === 0) {
|
||||||
|
return (
|
||||||
|
<Card className="bg-surface border-rule">
|
||||||
|
<CardContent className="px-6 py-12 text-center text-ink-muted">
|
||||||
|
לא נמצאו סוכנים
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 (
|
||||||
|
<div className="space-y-4">
|
||||||
|
<Card className="bg-surface border-rule shadow-sm">
|
||||||
|
<CardContent className="px-5 py-4 flex items-center justify-between gap-3 flex-wrap">
|
||||||
|
<div className="text-[0.85rem] text-ink-muted">
|
||||||
|
{data.pairs.length} סוכנים × 2 חברות (CMP master / CMPA mirror)
|
||||||
|
{totalDrift > 0 && (
|
||||||
|
<span className="text-warn ms-2">
|
||||||
|
· {totalDrift} פערי סנכרון
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{missingCount > 0 && (
|
||||||
|
<span className="text-danger ms-2">· {missingCount} זוגות לא שלמים</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => refetch()}
|
||||||
|
disabled={isFetching}
|
||||||
|
>
|
||||||
|
רענון
|
||||||
|
</Button>
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
<div className="space-y-3">
|
||||||
|
{data.pairs.map((pair) => (
|
||||||
|
<PairCard key={pair.name} pair={pair} />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
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 { AppShell } from "@/components/app-shell";
|
||||||
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
|
||||||
import { PaperclipTab } from "./_components/paperclip-tab";
|
import { PaperclipTab } from "./_components/paperclip-tab";
|
||||||
@@ -9,6 +9,7 @@ import { EnvironmentTab } from "./_components/environment-tab";
|
|||||||
import { ToolsTab } from "./_components/tools-tab";
|
import { ToolsTab } from "./_components/tools-tab";
|
||||||
import { RegistrationsTab } from "./_components/registrations-tab";
|
import { RegistrationsTab } from "./_components/registrations-tab";
|
||||||
import { BlocksTab } from "./_components/blocks-tab";
|
import { BlocksTab } from "./_components/blocks-tab";
|
||||||
|
import { AgentsTab } from "./_components/agents-tab";
|
||||||
|
|
||||||
export default function SettingsPage() {
|
export default function SettingsPage() {
|
||||||
return (
|
return (
|
||||||
@@ -36,6 +37,10 @@ export default function SettingsPage() {
|
|||||||
<Building2 className="w-4 h-4" data-icon="inline-start" />
|
<Building2 className="w-4 h-4" data-icon="inline-start" />
|
||||||
Paperclip
|
Paperclip
|
||||||
</TabsTrigger>
|
</TabsTrigger>
|
||||||
|
<TabsTrigger value="agents">
|
||||||
|
<Bot className="w-4 h-4" data-icon="inline-start" />
|
||||||
|
סוכנים
|
||||||
|
</TabsTrigger>
|
||||||
<TabsTrigger value="environment">
|
<TabsTrigger value="environment">
|
||||||
<Server className="w-4 h-4" data-icon="inline-start" />
|
<Server className="w-4 h-4" data-icon="inline-start" />
|
||||||
סביבה
|
סביבה
|
||||||
@@ -55,6 +60,7 @@ export default function SettingsPage() {
|
|||||||
</TabsList>
|
</TabsList>
|
||||||
|
|
||||||
<TabsContent value="paperclip"><PaperclipTab /></TabsContent>
|
<TabsContent value="paperclip"><PaperclipTab /></TabsContent>
|
||||||
|
<TabsContent value="agents"><AgentsTab /></TabsContent>
|
||||||
<TabsContent value="environment"><EnvironmentTab /></TabsContent>
|
<TabsContent value="environment"><EnvironmentTab /></TabsContent>
|
||||||
<TabsContent value="tools"><ToolsTab /></TabsContent>
|
<TabsContent value="tools"><ToolsTab /></TabsContent>
|
||||||
<TabsContent value="blocks"><BlocksTab /></TabsContent>
|
<TabsContent value="blocks"><BlocksTab /></TabsContent>
|
||||||
|
|||||||
71
web-ui/src/lib/api/paperclip-agents.ts
Normal file
71
web-ui/src/lib/api/paperclip-agents.ts
Normal file
@@ -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<PaperclipAgentsResponse>("/api/admin/paperclip-agents", { signal }),
|
||||||
|
staleTime: 30_000,
|
||||||
|
});
|
||||||
|
}
|
||||||
139
web/app.py
139
web/app.py
@@ -44,7 +44,9 @@ from web.mcp_env_catalog import (
|
|||||||
normalize_for_compare,
|
normalize_for_compare,
|
||||||
)
|
)
|
||||||
from web.progress_store import ProgressStore
|
from web.progress_store import ProgressStore
|
||||||
|
from web.paperclip_api import pc_request
|
||||||
from web.paperclip_client import (
|
from web.paperclip_client import (
|
||||||
|
COMPANIES as PAPERCLIP_COMPANIES,
|
||||||
accept_interaction as pc_accept_interaction,
|
accept_interaction as pc_accept_interaction,
|
||||||
archive_project as pc_archive_project,
|
archive_project as pc_archive_project,
|
||||||
create_project as pc_create_project,
|
create_project as pc_create_project,
|
||||||
@@ -3172,6 +3174,143 @@ async def api_list_skills():
|
|||||||
return 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")
|
@app.post("/api/admin/skills/install")
|
||||||
async def api_install_skill(file: UploadFile = File(...)):
|
async def api_install_skill(file: UploadFile = File(...)):
|
||||||
"""Install or update a Paperclip skill from a ZIP file.
|
"""Install or update a Paperclip skill from a ZIP file.
|
||||||
|
|||||||
Reference in New Issue
Block a user