diff --git a/api/vendor/agentfox-0.3.1-py3-none-any.whl b/api/vendor/agentfox-0.3.1-py3-none-any.whl index a0c5e866..36ac0c95 100644 Binary files a/api/vendor/agentfox-0.3.1-py3-none-any.whl and b/api/vendor/agentfox-0.3.1-py3-none-any.whl differ diff --git a/dashboard/app/(product)/app/agents/[slug]/page.tsx b/dashboard/app/(product)/app/agents/[slug]/page.tsx index 22d2d741..2a419df3 100644 --- a/dashboard/app/(product)/app/agents/[slug]/page.tsx +++ b/dashboard/app/(product)/app/agents/[slug]/page.tsx @@ -1,608 +1,452 @@ import type { Metadata } from "next"; -import { appPageMetadata } from "@/lib/site"; import Link from "next/link"; -import { api, safeApi, apiErrorProps } from "@/lib/product/api"; -import { ApiDown, InfoTip, InventoryStrip, Panel, Severity, ts } from "@/components/ui"; -import { Breadcrumbs } from "@/components/product/Breadcrumbs"; +import { appPageMetadata } from "@/lib/site"; +import { ApiError, api, apiErrorProps, safeApi } from "@/lib/product/api"; +import { ApiDown, NotFound } from "@/components/ui"; +import { + ActionPill, + BarList, + Card, + Empty, + Grid, + Header, + Kpi, + Meta, + ModePill, + Pill, + SeverityPill, + Sparkline, + Tabs, + TimeChart, + ago, + href, + num, + pctOf, +} from "@/components/kit"; +import { FilterBar } from "@/components/kit/FilterBar"; +import { RunsTable } from "@/components/kit/RunsTable"; import { AgentMap } from "@/components/product/AgentMap"; +import { AccessEditor } from "@/components/product/agent/AccessEditor"; +import { RANGE_DAYS, metricsQs, runsHref, verdictsFor, type Filters } from "@/lib/product/observe"; +import { CATEGORIES, categoryLabel, rangeOf, ruleCategory, ruleTitle } from "@/lib/product/vocab"; -/** - * Behind the sign-in wall: `noindex`, plus a tab title that is not the fourth - * copy of "AgentFox Control Plane". See lib/site.ts appPageMetadata. - */ export const metadata: Metadata = appPageMetadata("Agent"); - export const dynamic = "force-dynamic"; -/** Split by the question being asked, not by where the data comes from. */ -const TABS: { key: string; label: string }[] = [ +const TABS = [ + { key: "overview", label: "Overview" }, + { key: "access", label: "Access" }, + { key: "rules", label: "Rules" }, { key: "activity", label: "Activity" }, - { key: "permissions", label: "What it may do" }, - { key: "registration", label: "Registration" }, + { key: "quality", label: "Quality" }, + { key: "settings", label: "Settings" }, ]; -/** - * The four tiers a tool can be declared at (`agentfox declare tool --impact`). - * high_impact was missing here, so a tool at that tier rendered as an untoned - * tag — visually identical to a read-only one, which is the opposite of what it - * means. Every containment rule reasons over this axis, so it has to be complete. - */ -const IMPACT_TONE: Record = { - read: "", - write: "warn", - high_impact: "warn", - irreversible: "bad", -}; +/** Old tab names, so bookmarked links still land somewhere sensible. */ +const LEGACY: Record = { permissions: "rules", registration: "settings" }; -const IMPACT_MEANS: Record = { - read: "Returns information and changes nothing.", - write: "Changes something, and the change can be undone.", - high_impact: "Significant effect, but still reversible — untrusted arguments send it for approval.", - irreversible: "Cannot be undone. Untrusted arguments always require a human.", -}; +type SP = Record; -export default async function AgentDetail({ - params, - searchParams, -}: { - params: Promise<{ slug: string }>; - searchParams: Promise<{ tab?: string; review_error?: string; review_notice?: string }>; -}) { +export default async function AgentDetail({ params, searchParams }: { params: Promise<{ slug: string }>; searchParams: Promise }) { const { slug } = await params; - const { tab: rawTab, review_error, review_notice } = await searchParams; - const tab = TABS.some((t) => t.key === rawTab) ? rawTab! : "activity"; - let posture: any, lineage: any, traces: any, classification: any, boundaries: any, controls: any, effective: any, tools: any, mcpServers: any; + const sp = await searchParams; + const requested = LEGACY[sp.tab || ""] || sp.tab; + const tab = TABS.some((t) => t.key === requested) ? requested! : "overview"; + const f: Filters = { range: rangeOf(sp.range), agent: slug }; + + let posture: any; try { - [posture, lineage, traces, classification, boundaries, controls, effective, tools, mcpServers] = await Promise.all([ - api(`/api/agents/${slug}/posture`), - safeApi(`/api/agents/${slug}/lineage?depth=2`, { nodes: [], links: [], blast_radius: 0 }), - safeApi(`/api/traces?agent=${slug}&limit=15`, { traces: [] }), - safeApi(`/api/risk/classify/${slug}`, null), - safeApi(`/api/answerability/boundaries`, { boundaries: [], question_types: [] }), - safeApi(`/api/agent-controls`, { controls: [] }), - safeApi(`/api/policies/effective?agent=${slug}`, null), - safeApi(`/api/tools`, { tools: [] }), - safeApi(`/api/mcp-servers`, { servers: [] }), - ]); + posture = await api(`/api/agents/${encodeURIComponent(slug)}/posture`); } catch (e: any) { return ( <> -

{slug}

- +
+ {e instanceof ApiError && e.status === 404 ? ( + + ) : ( + + )} ); } - const a = posture.agent; - const lineageNodeType: Record = {}; - for (const n of lineage.nodes || []) lineageNodeType[n.id] = n.type; - const boundary = boundaries.boundaries.find((b: any) => b.agent === a.slug) || null; - const control = (controls.controls || []).find((c: any) => c.agent === a.slug) || null; - const state = control?.state || "active"; - const toolByKey: Record = {}; - for (const t of tools.tools || []) toolByKey[t.key] = t; - const mcpById: Record = {}; - for (const s of mcpServers.servers || []) mcpById[s.id] = s; - const questionTypes: string[] = boundaries.question_types?.length - ? boundaries.question_types - : ["fact", "aggregate", "prediction", "opinion", "procedure"]; + const controls = await safeApi("/api/agent-controls", { controls: [] }); + const state = (controls.controls || []).find((c: any) => c.agent === a.slug)?.state || "active"; + const tabHref = (k: string) => href(`/app/agents/${encodeURIComponent(slug)}`, { tab: k === "overview" ? undefined : k, range: sp.range }); return ( <> - -

- {a.name || a.slug} - {a.is_seed && ( - - sample data - - )} -

- {a.name &&

{a.slug}

} - -

- What this agent is, what it has actually been doing, and what it is allowed - to do. Come here to decide whether it is safe to leave running as it is. -

- - {review_error &&
{review_error}
} - {review_notice &&
{review_notice}
} - - {state !== "active" && ( -
- This agent is {state} - {control?.reason && <> — {control.reason}} - {control?.actor && ({control.actor}, {ts(control.changed_at)})} - . Every governed call is currently refused until it's resumed. -
- )} - - - {/* Six tiles, and on a quiet agent all six read 0 — three of them wearing a - green border to celebrate it. Same rule as everywhere else in here: a - number takes colour only when it is a problem, and a row of counts is a - strip, not six cards. */} - + {state === "active" ? Active : {state === "killed" ? "Stopped" : "Paused"}} + {a.risk_tier === "high" && High risk} + {!a.registered && Unregistered} + {a.is_seed && Sample} + + } + actions={ +
+ + +
+ } + /> + Assign], + ["Team", a.owner_team || "—"], + ["Environment", a.environment], + ["Framework", a.framework || "—"], + ["Last active", ago(a.last_seen_at)], ]} /> +
+ {sp.review_error &&
{sp.review_error}
} + {sp.review_notice &&
Done: {sp.review_notice}.
} + ({ ...t, href: tabHref(t.key) }))} active={tab} /> - {/* Ten sections in one scroll — 1,072 words over 5,173px — is a page you - navigate by scrollbar. Three tabs, split by the question being asked: - what has it been doing, what is it allowed to do, and what is it - declared as. The summary and the state banner stay above them, because - "this agent is quarantined" must not be one tab-click away. */} -
- {TABS.map((t) => ( - - {t.label} - - ))} -
- - {tab === "activity" && ( - <> - {posture.open_findings?.length > 0 && ( - <> -

Open findings

-
- - - - {posture.open_findings.map((f: any) => ( - - - - - - ))} - -
severitytypefinding
{f.type} - {f.title} -
-
- - )} - + {tab === "overview" && } + {tab === "access" && } + {tab === "rules" && } + {tab === "activity" && } + {tab === "quality" && } + {tab === "settings" && } + + ); +} -

Recent traces

-
- {traces.traces.length === 0 ? ( -
- No traces recorded yet. - {posture.handoffs > 0 && ( - <> - {" "}This agent does have {posture.handoffs} hand-off{posture.handoffs === 1 ? "" : "s"} on - record — hand-offs are logged independently of traced calls, see{" "} - Escalation. - - )} -
+async function Overview({ f, posture }: { f: Filters; posture: any }) { + const [s, rules, lineage] = await Promise.all([ + safeApi(`/api/metrics/summary?${metricsQs(f)}`, null), + safeApi(`/api/metrics/rules?${metricsQs(f)}`, { rules: [] }), + safeApi(`/api/agents/${encodeURIComponent(f.agent!)}/lineage?depth=2`, { nodes: [], links: [], blast_radius: 0 }), + ]); + const t = s?.totals || {}; + const p = s?.previous || {}; + const b = s?.buckets || []; + const findings: any[] = posture.open_findings || []; + return ( + <> + + + x.allowed + x.masked + x.held + x.blocked)} /> + x.blocked)} /> + x.held)} /> + + + {s && ( + + runsHref(f, { start, end })} /> + + )} + + + ({ + key: r.rule_id, + label: ruleTitle(r.rule_id), + href: `/app/policies/rules/${encodeURIComponent(r.rule_id)}`, + value: r.fires, + note: r.watched && !r.enforced ? "watching" : undefined, + }))} + /> + + All} flush> + {findings.length ? ( +
    + {findings.slice(0, 6).map((x) => ( +
  • +
    + {x.title} +
    + +
  • + ))} +
) : ( - - - - {traces.traces.map((t: any) => ( - - - - - - - - ))} - -
traceverdictmodelintentwhen
{t.id}{t.verdict}{t.model || "—"}{t.intent || "—"}{ts(t.started_at)}
+ No open issues. )} -
- - {posture.slos?.length > 0 && ( - <> -

Reliability objectives

-
- - - - - - {posture.slos.map((s: any) => ( - - - - - - - - - ))} - -
scorerobjectivetargetattainmenterror budgetstatus
{s.scorer}{s.objective || "—"}{s.target ?? "—"}{s.attainment ?? "—"}{s.error_budget_remaining ?? "—"}{s.status}
-
- - )} - - - )} - - {tab === "permissions" && ( - <> - {effective && ( - <> -
-

- Effective policy - {effective.rules?.some((r: any) => r.source && !r.source.startsWith("org:")) && ( - - customized - - )} - -

- - + Customize for this agent - -
-
-
- mode {effective.mode} · default effect{" "} - {effective.default_effect} · layers:{" "} - {effective.layers?.length ? effective.layers.join(", ") : "none apply"} -
- {effective.rules?.length > 0 && ( - - - - - - {effective.rules.map((r: any) => ( - - - - - - - - ))} - -
what it checkseffectsourcemode
- {r.description || r.rule_id} -
{r.rule_id}
-
{r.effect} - {r.source} - {r.source && !r.source.startsWith("org:") && ( - custom - )} - {r.mode}{r.loosened && loosened}
- )} - {effective.rejected?.length > 0 && ( -
- {effective.rejected.length} rule(s) rejected during composition: -
    - {effective.rejected.map((r: any, i: number) => ( -
  • {r.rule_id ? `${r.rule_id}: ` : ""}{r.message || r.code}
  • - ))} -
-
- )} -
- - )} - - -

Knowledge boundary

- -
-
- - -
-
-
- - -
-
- - -
-
-
- - -
-
- - -
-
- -
- {questionTypes.map((qt) => ( - - ))} -
-
-
- -
-
-
- + + + {lineage.links?.length > 0 && ( + )} + + ); +} - {tab === "registration" && ( - <> -
- - {!a.purpose && not set} - - -
- - {/* The reach, drawn, before the reach listed. The table below is the same - data and stays — it is what you read when you need the exact counts — - but "how far does this thing go" is a shape, not four columns. */} - {lineage.links?.length > 0 && ( - (`/api/agents/${encodeURIComponent(slug)}/access`, null), + safeApi("/api/answerability/boundaries", { boundaries: [], question_types: [] }), + ]); + const boundary = (boundaries.boundaries || []).find((b: any) => b.agent === slug) || null; + const types: string[] = boundaries.question_types?.length ? boundaries.question_types : ["fact", "aggregate", "prediction", "opinion", "procedure"]; + return ( + <> + {access ? ( + + ) : ( + + Access could not be loaded. + )} + Topic rule} + > +
+
+ + +
+
+ + +
+
+ + +
+
+ + + + months + + hours fresh + +
+
+ + + {types.map((qt) => ( + + ))} + +
+
+ + + + +
+
+
+ + ); +} -
- - - - - - - - - - - - - - - -
owner{a.owner_email || unowned}
-
- - - -
-
team{a.owner_team || "—"}
environment{a.environment}
risk tier{a.risk_tier}
framework{a.framework || "—"}
registered{a.registered ? yes : unregistered}
declared models{a.declared_models?.join(", ") || "—"}
declared tools{a.declared_tools?.join(", ") || "—"}
data classes{a.data_classes?.join(", ") || "—"}
last seen{ts(a.last_seen_at)}
-
+async function Rules({ f, slug }: { f: Filters; slug: string }) { + const [effective, stats] = await Promise.all([ + safeApi(`/api/policies/effective?agent=${encodeURIComponent(slug)}`, { rules: [] }), + safeApi(`/api/metrics/rules?${metricsQs({ ...f, range: "30d" })}`, { rules: [] }), + ]); + const byId: Record = Object.fromEntries((stats.rules || []).map((r: any) => [r.rule_id, r])); + const rules: any[] = effective.rules || []; + const groups = CATEGORIES.map((c) => ({ c, rules: rules.filter((r) => ruleCategory(r.rule_id) === c.key) })).filter((g) => g.rules.length); + return ( + <> +
+ + {rules.length} rules apply · fired in the last 30 days + +
+ + Add rule for this agent + +
+
+ {groups.map(({ c, rules }) => ( + + + + {rules.map((r) => { + const s = byId[r.rule_id]; + return ( + + + + + + + + ); + })} + +
+ + {ruleTitle(r.rule_id)} + + {r.source && !String(r.source).startsWith("org:") && Custom for this agent} + {s ? : null}{s ? {num(s.fires)} : 0}
+
+ ))} + + ); +} - - derived from traces, not config{" "} - - - } - > - {lineage.links.length === 0 ? ( -
No relationships observed yet.
- ) : ( - - - - - - {lineage.links.map((l: any, i: number) => ( - - - - - - - ))} - -
fromrelationtoseen
- {lineageNodeType[l.source] === "agent" ? ( - {l.source} - ) : ( - l.source - )} - {l.relation} - {lineageNodeType[l.target] === "agent" ? ( - {l.target} - ) : ( - l.target - )} - {toolByKey[l.target] && ( - <> - {" "} - - {toolByKey[l.target].impact} - - {toolByKey[l.target].mcp_server_id && mcpById[toolByKey[l.target].mcp_server_id] && ( - - {mcpById[toolByKey[l.target].mcp_server_id].trust_level} - - )} - - )} - {l.observed_count}
- )} -
+async function Activity({ f, outcome }: { f: Filters; outcome?: string }) { + const qs = new URLSearchParams({ agent: f.agent!, limit: "100", since_days: String(RANGE_DAYS[f.range]) }); + const v = verdictsFor(outcome); + if (v) qs.set("verdict", v); + if (outcome === "errors") qs.set("errors", "true"); + const runs = await safeApi(`/api/traces?${qs}`, { traces: [] }); + const chip = (key: string, label: string) => ( + + {label} + + ); + return ( + <> + +
+ {chip("", "All")} + {chip("blocked", "Blocked")} + {chip("held", "Held")} + {chip("errors", "Errors")}
+
+ + + + + ); +} - {classification && ( - <> -

- Proposed risk classification - -

- -
- {classification.signals.length ? ( -
    - {classification.signals.map((s: string) => ( -
  • {s}
  • - ))} -
- ) : ( -
No elevating signals observed.
- )} -
- Requires human confirmation - {classification.caveat} -
- {classification.proposed_class !== classification.current_class && ( -
- - {" "} - - or leave as recorded ({classification.current_class}) to reject. - -
- )} -
-
- +async function Quality({ slug, posture }: { slug: string; posture: any }) { + const report = await safeApi(`/api/escalation/report?agent=${encodeURIComponent(slug)}`, null); + const slos: any[] = posture.slos || []; + return ( + <> + {report && ( + + + + + + + )} + Tests} flush> + {slos.length ? ( + + + + + + + + + + + {slos.map((s) => ( + + + + + + + ))} + +
MeasureTargetActualStatus
{s.scorer}{s.target != null ? `${Math.round(s.target * 100)}%` : "—"}{s.attainment != null ? `${Math.round(s.attainment * 100)}%` : "—"} + + {String(s.status || "no data").replace(/_/g, " ")} + +
+ ) : ( + Set a target}>No reliability targets. )} +
+ + ); +} -

- Kill switch - -

-
-
- {state} -
- - - {state === "active" ? ( - - ) : ( - - )} -
+function Settings({ a, state }: { a: any; state: string }) { + const action = `/api/agents/${encodeURIComponent(a.slug)}/owner`; + return ( + <> + +
+
+ + +
+
+ + +
+
+ +
+
+ + +
+
+ + + + +
+
+
+ + + + + + + + + +
Slug{a.slug}
Registered{a.registered ? "Yes" : No}
Models{a.declared_models?.join(", ") || "—"}
Data it handles{a.data_classes?.join(", ") || "—"}
First seen{ago(a.first_seen_at)}
+
+ +
+ {state === "active" ? Active : {state}} +
+ + + +
{state !== "killed" && ( -
- Stronger incident action, different role: -
- - -
-
+
+ + +
)}
- - )} +
); } diff --git a/dashboard/app/(product)/app/agents/page.tsx b/dashboard/app/(product)/app/agents/page.tsx index fe10a4c0..b7a3564b 100644 --- a/dashboard/app/(product)/app/agents/page.tsx +++ b/dashboard/app/(product)/app/agents/page.tsx @@ -1,451 +1,184 @@ import type { Metadata } from "next"; -import { appPageMetadata } from "@/lib/site"; import Link from "next/link"; -import { api, apiErrorProps } from "@/lib/product/api"; -import { ApiDown, InfoTip, InventoryStrip, Panel, Stat, ts } from "@/components/ui"; -import { PageHeader } from "@/components/product/PageHeader"; -import { Explainer } from "@/components/product/Explainer"; +import { appPageMetadata } from "@/lib/site"; +import { api, apiErrorProps, safeApi } from "@/lib/product/api"; +import { ApiDown } from "@/components/ui"; +import { Card, Dot, Empty, Header, Pill, StackBar, Tabs, ago, href, num, pctOf } from "@/components/kit"; import { Modal } from "@/components/product/Modal"; -/** - * Behind the sign-in wall: `noindex`, plus a tab title that is not the fourth - * copy of "AgentFox Control Plane". See lib/site.ts appPageMetadata. - */ -export const metadata: Metadata = appPageMetadata( - "Agents", - "The register of every AI agent in this workspace.", -); - +export const metadata: Metadata = appPageMetadata("Agents", "Every AI agent in this workspace."); export const dynamic = "force-dynamic"; -export default async function Agents({ - searchParams, -}: { - searchParams: Promise<{ review_error?: string }>; -}) { - const { review_error } = await searchParams; - let agents: any, shadow: any; +type SP = Record; + +export default async function Agents({ searchParams }: { searchParams: Promise }) { + const sp = await searchParams; + let agents: any; try { - [agents, shadow] = await Promise.all([ - api("/api/agents"), - api("/api/discovery/shadow"), - ]); + agents = await api("/api/agents"); } catch (e: any) { return ( <> -

Agents

+
); } - - const inv = agents.inventory; - const drafts = agents.agents.filter((a: any) => a.status === "draft"); - - // A repo scan registers one "agent" per directory with governable code in it, - // including test suites and utility scripts — those aren't things that talk to - // customers, and listing them next to real production agents with no distinction - // makes it impossible to tell which of N rows is the one that actually matters. - const looksLikeTestOrScript = (slug: string) => /(^|-)(tests?|specs?|scripts?|examples?|demo|fixtures?)($|-)/i.test(slug); - const realAgents = agents.agents.filter((a: any) => !looksLikeTestOrScript(a.slug)); - const testLikeAgents = agents.agents.filter((a: any) => looksLikeTestOrScript(a.slug)); + const [usage, findings] = await Promise.all([ + safeApi("/api/metrics/breakdown?dim=agent&range=7d", { rows: [] }), + safeApi("/api/findings?status=open", { findings: [] }), + ]); + + const all: any[] = agents.agents || []; + const proposed = all.filter((a) => a.status === "draft"); + const live = all.filter((a) => a.status !== "draft"); + const attention = live.filter((a) => !a.registered || !a.owner_email); + const tab = sp.tab === "attention" ? "attention" : sp.tab === "proposed" ? "proposed" : "all"; + const rows = tab === "attention" ? attention : live; + + const byAgent: Record = Object.fromEntries((usage.rows || []).map((r: any) => [r.key, r])); + const issues: Record = {}; + for (const f of findings.findings || []) if (f.agent_slug) issues[f.agent_slug] = (issues[f.agent_slug] || 0) + 1; + const max = Math.max(1, ...Object.values(byAgent).map((r: any) => r.requests)); return ( <> - } - /> - - {/* One row, not two. - - This was a .cards grid holding the two problem numbers, above a separate - InventoryStrip holding the other four — so a page with one unregistered - agent showed two enormous tiles stretched across the full 1010px (the - grid is auto-fit, and two items share the whole width between them) and - then a second, denser row of statistics directly underneath. Two - treatments of the same kind of thing, stacked. - - The tile treatment was earning its loudness on the Overview, where - nothing else says "look at this". Here the "Unregistered agents" table - is immediately below and does exactly that, so the tiles were shouting a - heading that the next element already carries. One strip, with tone on - the two entries that are problems, and the section below does the rest. */} - } /> + - - {review_error &&
{review_error}
} - - {drafts.length > 0 && ( - <> -

Pending review

- - - - - - - - - - - - {drafts.map((a: any) => ( - - - - - - - ))} - -
agentpurposeframework
{a.slug} - {a.purpose || "—"} - {a.framework || "—"} -
-
- -
-
- -
-
-
-
- - )} - - {shadow.shadow_agents.length > 0 && ( - <> -

Unregistered agents

- - + {sp.review_error &&
{sp.review_error}
} + + {tab === "proposed" ? ( + + {proposed.length ? ( +
    + {proposed.map((a) => ( +
  • +
    + {a.name || a.slug} + Found by a repo scan{a.framework ? ` · ${a.framework}` : ""} +
    +
    +
    + + +
    + + +
    +
  • + ))} +
+ ) : ( + Scan a repo}>Nothing proposed. + )} +
+ ) : ( + + {rows.length ? ( +
- - - - - - + + + + + + + + - {shadow.shadow_agents.map((s: any) => ( - - - - - - - - - ))} - -
agentenvironmentcallsmodelsframeworkfirst seenAgentOwnerRiskLast 7 daysRequestsStoppedIssuesLast active
{s.slug}{s.environment}{s.calls}{s.models.join(", ") || "—"}{s.framework || "—"}{ts(s.first_seen)}
-
- - )} - -

All agents

- {realAgents.length === 0 ? ( - // Muted text and no way forward was the whole state a new workspace got - // here. Both routes into the registry belong in it, because which one - // applies depends on where the agent lives, not on which is preferred. -
-
- {agents.agents.length === 0 - ? "No agents registered yet" - : "Nothing here looks like a production agent yet"} -
-

- {agents.agents.length === 0 - ? "The list of agents you are accountable for." - : "Everything found so far looks like tests, scripts or examples — listed further down."} -

-

- Two ways in.{" "} - -

-
- - Connect a repo - - -
-
- ) : ( -
- - - - - - - - - - - - - - {realAgents.map((a: any) => ( - - + - - - - - - - - ))} - -
agentpurposeownerenvriskframework - last seen - -
- {a.name || a.slug} - {a.name &&
{a.slug}
} - {a.status === "shadow" &&
unregistered
} - {a.status === "draft" &&
draft
} - {a.is_seed && ( -
- - sample data + {rows.map((a) => { + const u = byAgent[a.slug] || { requests: 0, held: 0, blocked: 0 }; + return ( +
+ + + + {a.name || a.slug} + + {!a.registered && Unregistered} - - )} - - {a.purpose || "—"} - - {a.owner_email || ( - - unowned — assign - - )} - {a.environment} - - {a.risk_tier} - - {a.framework || "—"}{ts(a.last_seen_at)}
-
- )} - - {testLikeAgents.length > 0 && ( -
- - {testLikeAgents.length} more that look like tests, scripts or examples - -
- - - - - - {testLikeAgents.map((a: any) => ( - - - - - - - - ))} + {a.purpose && {a.purpose}} + + + + + + + + + + ); + })}
agentownerenvframeworklast seen
{a.slug}{a.owner_email || "unowned"}{a.environment}{a.framework || "—"}{ts(a.last_seen_at)}
+ {a.owner_email ? ( + {a.owner_email} + ) : ( + Assign + )} + + {a.risk_tier || "—"} + + + {num(u.requests)}{u.requests ? pctOf(u.held + u.blocked, u.requests) : "—"} + {issues[a.slug] ? {issues[a.slug]} : 0} + {ago(a.last_seen_at)}
-
-
+ ) : ( + Connect an agent}> + {tab === "attention" ? "Every agent is registered and owned." : "No agents yet."} + + )} + )} - - ); } -/** - * Half of containment ships working, is used on every governed tool call, and has - * no screen anywhere in the dashboard. A reader of this page can see which tools an - * agent *uses* and has no way to learn that what it is *allowed* to use is a - * separate, declared thing they can narrow. - */ -function CapabilityGrants() { +function RegisterAgent() { return ( - <> - {/* Was a heading, three paragraphs in a callout, a seven-row command table - and a closing note — roughly 300 words of mechanism, permanently open, at - the foot of a page somebody opened to look at their agents. The claim is - the part that has to be visible; the mechanism is the part you read once. - So the claim stays and the rest is behind the button. */} -

What an agent is allowed to do

-

- The table above is what these agents have been seen calling. What - they are permitted to call is a separate declaration — a capability - grant — and it is default deny: an agent with no grant for a tool cannot call - it at all. Grants are made from the command line; there is no screen for them - yet. -

- -

- A grant says this agent may call this tool, and on what terms: which - actions, limits on the values in the arguments, a ceiling on how untrusted - those arguments may be, whether the call needs a human approval first, and - a date the grant expires. -

-

- This is one half of whether an action runs. The other half is the{" "} - impact tier on the tool - itself — how much damage it can do. Together they are the reason a prompt - injection can succeed at convincing the model and still not get the action - executed. -

-

- A grant contains only what has been declared. A tool - declared read that in fact deletes records is - not contained by any of this, and a grant is not evidence that a tool - behaves as described. -

- -

Commands

-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
to do thiscommand
Let an agent call a toolagentfox permit grant AGENT TOOL
Cap what the arguments may say--limit amount:lte=500 --action refund,lookup
Refuse arguments from something untrusted--max-taint user
Send the call to a human first--requires-approval
Make the grant expire on its own--expires-in-days 30
See what an agent currently holdsagentfox permit list AGENT
Take one backagentfox permit revoke CAPABILITY_ID
+ +
+
+ +
-

- Granting writes capability.granted to the - audit chain — evidence on the{" "} - Compliance page. A call - sent for sign-off arrives in{" "} - Approvals. Over HTTP:{" "} - POST /api/identities/{"{id}"}/capabilities. -

- - - - ); -} - -const fieldStyle = { - width: "100%", - padding: "5px 9px", - borderRadius: 6, - border: "1px solid var(--border)", - background: "var(--panel-2)", - color: "var(--text)", - fontSize: 13, - fontFamily: "inherit", -} as const; - -/** - * Rendered twice — once in the page header, once inside the no-agents empty - * state — so every input id is prefixed. Two copies of the same form on one page - * with the same ids would leave each `htmlFor` pointing at whichever input the - * browser saw first, which is the bug the labels were added to fix. - */ -function RegisterAgentModal({ idPrefix }: { idPrefix: string }) { - const id = (field: string) => `${idPrefix}-register-agent-${field}`; - return ( - -

- For an agent outside a scanned repo —{" "} - Connect is the repo-scan path. -

- -
- - +
+ +
-
- - +
+ +
-
- - +
+ +
-
-
- - -
-
- - -
+
+ +
-
- +
+ + + +
diff --git a/dashboard/app/(product)/app/approvals/page.tsx b/dashboard/app/(product)/app/approvals/page.tsx index 6800eec4..9b8cf097 100644 --- a/dashboard/app/(product)/app/approvals/page.tsx +++ b/dashboard/app/(product)/app/approvals/page.tsx @@ -1,547 +1,166 @@ import type { Metadata } from "next"; -import { appPageMetadata } from "@/lib/site"; import Link from "next/link"; -import { api, safeApi, apiErrorProps } from "@/lib/product/api"; -import { AgentLink, ApiDown, ArgsCell, Empty, InfoTip, InventoryStrip, Severity, ts } from "@/components/ui"; -import { PageHeader } from "@/components/product/PageHeader"; +import { appPageMetadata } from "@/lib/site"; +import { safeApi } from "@/lib/product/api"; +import { Card, Empty, Header, Pill, Tabs, ago, href } from "@/components/kit"; import { Countdown } from "@/components/product/Countdown"; +import { EscalationTab } from "@/components/product/approvals/escalation"; +import { TRUST } from "@/lib/product/vocab"; -/** - * Behind the sign-in wall: `noindex`, plus a tab title that is not the fourth - * copy of "AgentFox Control Plane". See lib/site.ts appPageMetadata. - */ -export const metadata: Metadata = appPageMetadata( - "Approvals", - "Actions held for a human decision, and the escalation queue.", -); - +export const metadata: Metadata = appPageMetadata("Approvals", "Actions waiting for a person."); export const dynamic = "force-dynamic"; -const STATUSES = ["pending", "approved", "denied", "expired", "used"]; -const TABS: { key: string; label: string }[] = [ - { key: "approvals", label: "Approvals" }, - { key: "escalation", label: "Escalation" }, -]; +type SP = Record; -/** - * `reason` is every fired rule's reason joined with "; " — three or four full - * sentences is normal. Showing all of them inline blows one row out to seven - * lines next to five one-line columns. Show the first rule's reason (the one - * that actually decided the verdict) and a count for the rest, full text on - * hover — the same "summary visible, detail on demand" split the table uses - * for arguments (truncated + monospace) already, just applied here too. - */ -/** - * Why this call needs a person, in full. - * - * This showed the first semicolon-separated part truncated to one line at 280px, - * with the rest behind a "+2 more" chip and the whole thing behind a tooltip. - * Each part is a separate rule that fired, and on this page the reason is the - * second most important thing after the arguments — it is what the approver is - * deciding against. Hiding two thirds of it behind a hover on the page where - * someone releases money is the wrong trade for one line of height. - */ -function ReasonCell({ reason }: { reason?: string }) { - if (!reason) return —; - const parts = reason.split(/;\s*/).filter(Boolean); - return ( -
    - {parts.map((part) => ( -
  • {part}
  • - ))} -
- ); +const HISTORY = ["approved", "denied", "expired", "used"]; +const STATUS_TONE: Record = { approved: "ok", used: "ok", denied: "bad", expired: "warn" }; + +function source(s?: string) { + return TRUST.find((t) => t.key === s)?.label.replace(/^\+ /, "") || s || ""; } -/** - * Approvals and Escalation are two parallel "a human has to act" queues — - * one tool call needing sign-off, one whole conversation needing a hand-off — - * that used to be two sidebar items. Same page now, tabs, same distinction - * spelled out in the intro so nobody confuses one for the other. - */ -export default async function Approvals({ - searchParams, -}: { - searchParams: Promise<{ - tab?: string; - status?: string; - agent?: string; - review_error?: string; - review_notice?: string; - }>; -}) { - const { tab: rawTab, status, agent, review_error, review_notice } = await searchParams; - const tab = TABS.some((t) => t.key === rawTab) ? rawTab! : "approvals"; +export default async function Approvals({ searchParams }: { searchParams: Promise }) { + const sp = await searchParams; + const tab = sp.tab === "history" ? "history" : sp.tab === "handoffs" || sp.tab === "escalation" ? "handoffs" : "pending"; + const pending = await safeApi("/api/approvals?status=pending", { approvals: [] }); return ( <> - - - {review_error &&
{review_error}
} - {review_notice &&
{review_notice}
} - -
- {TABS.map((t) => ( - - {t.label} - - ))} -
- - {tab === "approvals" ? ( - - ) : ( - - )} +
+ + {sp.review_error &&
{sp.review_error}
} + {sp.review_notice &&
Request {sp.review_notice}.
} + {tab === "pending" && } + {tab === "history" && } + {tab === "handoffs" && } ); } -async function ApprovalsTab({ status: rawStatus }: { status?: string }) { - const status = STATUSES.includes(rawStatus || "") ? rawStatus! : "pending"; - - let approvals: any, agents: any, others: any[]; - try { - // The other three statuses are fetched too, only for their counts. On a - // healthy estate the default view (pending) is legitimately empty, and the - // page then said so in one line and stopped — a filter bar whose four - // options all look identical, above nothing, with no indication that three - // of them have rows. Counts on the chips cost three small requests and turn - // a dead end into a direction. - const rest = STATUSES.filter((x) => x !== status); - const [self, agentList, ...restRes] = await Promise.all([ - api(`/api/approvals?status=${status}`), - safeApi("/api/agents", { agents: [] }), - ...rest.map((x) => safeApi(`/api/approvals?status=${x}`, { approvals: [] })), - ]); - approvals = self; - agents = agentList; - others = restRes; - } catch (e: any) { - return ; - } - - const counts: Record = { [status]: approvals.approvals?.length || 0 }; - STATUSES.filter((x) => x !== status).forEach((x, i) => { - counts[x] = others[i]?.approvals?.length || 0; - }); - const answered = counts.approved + counts.denied + counts.expired + counts.used; - - const agentSlug: Record = {}; - for (const a of agents.agents || []) agentSlug[a.id] = a.slug; +async function Pending({ approvals }: { approvals: any[] }) { + if (!approvals.length) return Nothing is waiting for you.; + const shown = approvals.slice(0, 25); + const [agents, traces] = await Promise.all([ + safeApi("/api/agents", { agents: [] }), + Promise.all(shown.map((a) => (a.trace_id ? safeApi(`/api/traces/${a.trace_id}`, null) : Promise.resolve(null)))), + ]); + const name: Record = Object.fromEntries((agents.agents || []).map((a: any) => [a.id, a])); return ( - <> -
- status: - {STATUSES.map((s) => ( - - {s} - {counts[s]} - - ))} -
- - {/* The panel is skipped entirely on an empty pending queue: the block - below already says it, and "No pending approvals." in a box directly - above "Nothing is waiting on you" is the same sentence twice. */} - {(approvals.approvals?.length > 0 || status !== "pending") && ( -
- {approvals.approvals?.length ? ( - - - - - - - - - - {status === "pending" && } - - - - {approvals.approvals.map((a: any) => ( - - - - - - - - {status === "pending" && ( - +
+ {shown.map((a, i) => { + const agent = name[a.agent_id]; + const decision = (traces[i]?.decisions || []).find((d: any) => d.id === a.decision_id) || traces[i]?.decisions?.[0]; + const origin: Record = decision?.taint?.arguments || {}; + return ( +
+
+
+
+ {agent?.name || "An agent"} wants to run {a.tool} +
+
+ {Object.entries(a.arguments || {}).map(([k, v]) => { + const from = origin[k]; + const risky = from && from !== "user" && from !== "none"; + return ( + + {k}: {typeof v === "string" ? v : JSON.stringify(v)} + {risky ? ` · from ${source(from).toLowerCase()}` : ""} + + ); + })} +
+
+ {String(a.reason || "").split(/(?<=\.)\s/)[0]} · expires + {a.trace_id && ( + <> + {" · "} + Run + )} -
- ))} - -
agenttoolreasonargumentsrequested - expires - -
- {agentSlug[a.agent_id] ? ( - - ) : ( - unattributed - )} - {a.tool || "—"} - - {ts(a.requested_at)}{status === "pending" ? : {ts(a.expires_at)}} -
-
- - -
-
- -
-
-
- ) : ( - No {status} approvals. - )} -
- )} - - {/* An empty pending queue is the healthy state, and it was rendered as one - sentence in a box on an otherwise blank page — a page that looks broken - rather than one that looks quiet. It says what it means instead: nothing - is waiting, here is what would put something here, and here is where the - answered ones are. */} - {status === "pending" && !approvals.approvals?.length && ( -
-

Nothing is waiting on you

-

- Every governed tool call was either allowed outright or already - answered. A call arrives here when the capability grant behind it says{" "} - --requires-approval, or when a policy rule - escalates rather than blocks — most often an irreversible tool called - with arguments that came from something untrusted. -

-

- {answered > 0 && ( - <> - - {answered} already answered - - {" · "} - - )} - What each agent may call - {" · "} - Which rules escalate -

-
- )} - +
+
+
+
+ + +
+
+ + +
+
+
+ + ); + })} + {approvals.length > shown.length &&
{approvals.length - shown.length} more waiting.
} +
); } -/** - * P11 — 31.1% of all catalogued failures, and the one control whose failure is - * invisible from inside the system. A conversation where the agent kept going - * instead of handing off looks entirely ordinary in the telemetry. - */ -async function EscalationTab({ agent }: { agent?: string }) { - const agentQs = agent ? `?agent=${encodeURIComponent(agent)}` : ""; - let report: any, missed: any, handoffs: any, agents: any, policy: any; - try { - [report, missed, handoffs, agents, policy] = await Promise.all([ - api(`/api/escalation/report${agentQs}`), - api(`/api/escalation/missed${agentQs}`), - api(`/api/escalation/handoffs${agentQs}`), - safeApi("/api/agents", { agents: [] }), - safeApi("/api/escalation/policy", null), - ]); - } catch (e: any) { - return ; - } - - const rate = report.missed_rate ?? 0; - const breaching = rate > 0.05; +async function History({ status }: { status?: string }) { + const statuses = status && HISTORY.includes(status) ? [status] : HISTORY; + const [lists, agents] = await Promise.all([ + Promise.all(statuses.map((s) => safeApi(`/api/approvals?status=${s}`, { approvals: [] }))), + safeApi("/api/agents", { agents: [] }), + ]); + const name: Record = Object.fromEntries((agents.agents || []).map((a: any) => [a.id, a])); + const rows = lists.flatMap((l) => l.approvals || []).sort((a, b) => String(b.requested_at).localeCompare(String(a.requested_at))); return ( <> -

- Conversations that met an escalation condition and never got a human.{" "} - -

- -
- - agent: - - - {agent && ( - clear agent × - )} -
- - {/* Five tiles, three of them zero and wearing a green border to say so, - above an empty "Missed escalations" section, above the hand-off queue — - which is the only thing on this tab that needs a person, and it started - 610px down the page. The counts are a strip, the queue comes first, and - the after-the-fact report follows it. */} - - -

Hand-off queue

-

- Each row needs a person.{" "} - -

-
- {handoffs.handoffs?.length ? ( - + + + + {rows.length ? ( +
- - - - - - - - + + + + - {handoffs.handoffs.map((h: any) => ( - - - - - + {rows.map((a) => ( + + + - - - - - ))} - -
conversationagentstatusownercontextduereasonWhenAgentActionOutcome
- - {h.summary || h.reason || "Conversation escalated"} - -
{h.session_id}
- {h.session_id?.startsWith("seed-") && ( -
- - sample data - -
- )} - {h.trace_id && ( -
- - trace - -
- )} -
- {h.agent_slug ? ( - - ) : ( - unattributed - )} - - - {h.status} - - {h.detected_retroactively && retroactive} - {h.owner_role}
{ago(a.requested_at)}{name[a.agent_id]?.name || "—"} - - {Math.round(h.completeness.score * 100)}% + {a.trace_id ? {a.tool} : {a.tool}} + + {Object.entries(a.arguments || {}) + .map(([k, v]) => `${k}: ${typeof v === "string" ? v : JSON.stringify(v)}`) + .join(" · ")} - {!h.completeness.complete && ( - missing {h.completeness.missing.join(", ")} - )} - {ts(h.due_at)}{h.reason?.slice(0, 80)} - {h.status === "pending" ? ( -
- - -
- ) : ( - — - )} -
- ) : ( - No hand-offs raised. - )} -
- -

Missed escalations

-

- Detected after the fact.{" "} - -

-
- {missed.missed?.length ? ( - - - - - - - - - - - - {missed.missed.map((m: any) => ( - - - - - - + ))}
conversationagentturnsqualified atwhy a human was needed
- - {m.session_id} - - - {m.agent_slug ? ( - - ) : ( - unattributed - )} - {m.turns}turn {m.first_qualifying_turn} - {m.triggers.slice(0, 2).map((t: any, i: number) => ( -
- {t.detail} -
- ))}
{a.status}
) : ( - - None in this window. Either escalation is working, or nothing has been - recorded yet — check Start here. - + No decisions yet. )} -
- - {/* The policy editor is configuration: a role, an SLA, a mode and a blob - of JSON conditions. It is not something anyone reads on the way to - clearing a queue, so it stops sitting under one. */} -
- Escalation policy — what qualifies a conversation for a hand-off -

- Escalation policy - -

-

- Every condition is a signal, not a guarantee.{" "} - -

-
-
-
- - -
-
- - -
-
- - -
-
-
- -