Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,17 @@ the file it came from.

### Added

- **A scanned multi-agent system comes out as its agents.** The repository scan reads
each agent the code defines, the tools it is handed and which agent hands work to
which (OpenAI Agents SDK handoffs and `as_tool`, LangGraph supervisors, swarms and
`StateGraph` agent nodes, CrewAI crews and delegation, AutoGen teams and group chats,
Google ADK `sub_agents`), statically. `scan --json` gains `agents` and `agent_edges`.
A repository with two or more agents is proposed as one draft per agent with its own
tools, joined by declared delegation edges, so the guardrail map shows sub-agents
("From code, no calls yet") and the agent that calls each one, and "apply to
sub-agents" reaches them, before any traffic. The Proposed list groups a system under
its orchestrator with an "Add all" action. A re-scan updates the drafts in place and
never turns an agent someone added back into a proposal.
- **`agentfox up`, the guided setup.** One command from a fresh install to a governed
project: it scans the repository, asks where AgentFox runs (this machine; self-hosted,
either started here with Docker Compose and a first operator, or an existing server;
Expand Down
Binary file modified api/vendor/agentfox-0.4.0-py3-none-any.whl
Binary file not shown.
2 changes: 1 addition & 1 deletion dashboard/app/(product)/app/approvals/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -81,7 +81,7 @@ export default async function ApprovalDetail({
<Card>
<Meta
items={[
["Agent", agent ? <Link href={`/app/agents/${agent.slug}`}>{agent.name || agent.slug}</Link> : "—"],
["Agent", agent ? <Link href={`/app/projects/${agent.slug}`}>{agent.name || agent.slug}</Link> : "—"],
["Requested", when(a.requested_at)],
pending
? ["Expires", <Countdown key="exp" at={a.expires_at} />]
Expand Down
26 changes: 17 additions & 9 deletions dashboard/app/(product)/app/approvals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -23,23 +23,31 @@ function source(s?: string) {
export default async function Approvals({ searchParams }: { searchParams: Promise<SP> }) {
const sp = await searchParams;
const tab = sp.tab === "history" ? "history" : sp.tab === "handoffs" || sp.tab === "escalation" ? "handoffs" : "pending";
const pending = await safeApi<any>("/api/approvals?status=pending", { approvals: [] });
const [all, agents] = await Promise.all([
safeApi<any>("/api/approvals?status=pending", { approvals: [] }),
safeApi<any>("/api/agents", { agents: [] }),
]);
// In a project's scope, only that project's requests.
const project = sp.agent ? (agents.agents || []).find((a: any) => a.slug === sp.agent || a.id === sp.agent) : null;
const mine = (list: any[]) => (sp.agent ? list.filter((a) => a.agent_id === (project?.id ?? sp.agent)) : list);
const pending = { approvals: mine(all.approvals || []) };
const q = sp.agent ? `agent=${encodeURIComponent(sp.agent)}` : "";

return (
<>
<Header title="Approvals" />
<Tabs
active={tab}
items={[
{ key: "pending", label: "Waiting", href: "/app/approvals", count: pending.approvals?.length },
{ key: "history", label: "History", href: "/app/approvals?tab=history" },
{ key: "handoffs", label: "Hand-offs", href: "/app/approvals?tab=handoffs" },
{ key: "pending", label: "Waiting", href: q ? `/app/approvals?${q}` : "/app/approvals", count: pending.approvals?.length },
{ key: "history", label: "History", href: `/app/approvals?tab=history${q ? `&${q}` : ""}` },
{ key: "handoffs", label: "Hand-offs", href: `/app/approvals?tab=handoffs${q ? `&${q}` : ""}` },
]}
/>
{sp.review_error && <div className="error">{sp.review_error}</div>}
{sp.review_notice && <div className="note-panel">Request {sp.review_notice}.</div>}
{tab === "pending" && <Pending approvals={pending.approvals || []} />}
{tab === "history" && <History status={sp.status} />}
{tab === "history" && <History status={sp.status} agent={sp.agent} only={mine} />}
{tab === "handoffs" && <EscalationTab agent={sp.agent} />}
</>
);
Expand Down Expand Up @@ -121,22 +129,22 @@ async function Pending({ approvals }: { approvals: any[] }) {
);
}

async function History({ status }: { status?: string }) {
async function History({ status, agent, only }: { status?: string; agent?: string; only: (list: any[]) => any[] }) {
const statuses = status && HISTORY.includes(status) ? [status] : HISTORY;
const [lists, agents] = await Promise.all([
Promise.all(statuses.map((s) => safeApi<any>(`/api/approvals?status=${s}`, { approvals: [] }))),
safeApi<any>("/api/agents", { agents: [] }),
]);
const name: Record<string, any> = 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)));
const rows = only(lists.flatMap((l) => l.approvals || [])).sort((a, b) => String(b.requested_at).localeCompare(String(a.requested_at)));

return (
<>
<div className="k-toolbar">
<div className="k-seg" role="group" aria-label="Status">
<Link href="/app/approvals?tab=history" className={!status ? "active" : ""} scroll={false}>All</Link>
<Link href={href("/app/approvals", { tab: "history", agent })} className={!status ? "active" : ""} scroll={false}>All</Link>
{HISTORY.map((s) => (
<Link key={s} href={href("/app/approvals", { tab: "history", status: s })} className={status === s ? "active" : ""} scroll={false}>
<Link key={s} href={href("/app/approvals", { tab: "history", status: s, agent })} className={status === s ? "active" : ""} scroll={false}>
{s[0].toUpperCase() + s.slice(1)}
</Link>
))}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ export const dynamic = "force-dynamic";
*/
/** Where the thing a control measures is set, for the controls a new workspace fails first. */
const FIX: Record<string, { label: string; href: string }> = {
"NOM-DSC-03": { label: "Assign owners on Agents", href: "/app/agents" },
"NOM-DSC-03": { label: "Assign owners on Agents", href: "/app/projects" },
"NOM-GOV-03": { label: "Assess risk in the risk register", href: "/app/compliance?tab=risk" },
"NOM-AUD-05": { label: "Set retention periods", href: "/app/compliance?tab=retention" },
"NOM-RTG-05": { label: "Turn on content checks", href: "/app/policies?tab=checks" },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,7 @@ export default async function FrameworkPage({ params }: { params: Promise<{ key:
o.agents_in_scope.slice(0, 6).map((slug: string, i: number) => (
<span key={slug}>
{i > 0 && ", "}
<Link href={`/app/agents/${encodeURIComponent(slug)}`}>{slug}</Link>
<Link href={`/app/projects/${encodeURIComponent(slug)}`}>{slug}</Link>
</span>
))
)}
Expand Down
2 changes: 1 addition & 1 deletion dashboard/app/(product)/app/compliance/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -231,7 +231,7 @@ export default async function Compliance({
<tbody>
{register.register.map((r: any) => (
<tr key={r.agent}>
<td className="mono small"><Link href={`/app/agents/${r.agent}`}>{r.agent}</Link></td>
<td className="mono small"><Link href={`/app/projects/${r.agent}`}>{r.agent}</Link></td>
<td><span className={`tag ${r.risk_tier === "high" ? "bad" : ""}`}>{r.risk_tier}</span></td>
<td className="small">
{r.eu_ai_act_class || <span className="tag warn">not assessed</span>}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ export default async function EscalationConversation({
fire, rather than trusting a summary of it.
{data.agent_slug && (
<>
{" "}Agent: <Link href={`/app/agents/${data.agent_slug}`}>{data.agent_name || data.agent_slug}</Link>.
{" "}Agent: <Link href={`/app/projects/${data.agent_slug}`}>{data.agent_name || data.agent_slug}</Link>.
</>
)}
</p>
Expand Down
2 changes: 1 addition & 1 deletion dashboard/app/(product)/app/evals/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -420,7 +420,7 @@ export default async function Evals({
threshold below is each scorer&rsquo;s built-in default, not a setting.
The number you actually choose is a <strong>reliability objective</strong>,
set per agent against a scorer, and visible on an agent&rsquo;s{" "}
<Link href="/app/agents">Activity tab</Link>.
<Link href="/app/projects">Activity tab</Link>.
</p>

<h4>Scorers that ship</h4>
Expand Down
34 changes: 34 additions & 0 deletions dashboard/app/(product)/app/features/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import type { Metadata } from "next";
import { appPageMetadata } from "@/lib/site";
import { api, apiErrorProps } from "@/lib/product/api";
import { ApiDown } from "@/components/ui";
import { Header } from "@/components/kit";
import { FeaturesEditor } from "@/components/product/features/FeaturesEditor";

export const metadata: Metadata = appPageMetadata("Features", "What every project gets, unless it changes it.");
export const dynamic = "force-dynamic";

/**
* The workspace's switches: each protection's level, whether projects may lower it,
* and which checks run. Every project starts from these and changes them on its own
* Features view.
*/
export default async function WorkspaceFeatures() {
let features: any;
try {
features = await api("/api/features");
} catch (e: any) {
return (
<>
<Header title="Features" />
<ApiDown {...apiErrorProps(e)} />
</>
);
}
return (
<>
<Header title="Features" hint="What every project gets. A project changes these for itself on its own Features view." />
<FeaturesEditor initial={features} />
</>
);
}
2 changes: 1 addition & 1 deletion dashboard/app/(product)/app/findings/[id]/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,7 @@ export default async function IssueDetail({
/>
<Meta
items={[
["Agent", f.agent_slug ? <Link href={`/app/agents/${encodeURIComponent(f.agent_slug)}`}>{f.agent_slug}</Link> : f.subject_id || "—"],
["Agent", f.agent_slug ? <Link href={`/app/projects/${encodeURIComponent(f.agent_slug)}`}>{f.agent_slug}</Link> : f.subject_id || "—"],
["Kind", type.label],
["Times", num(f.occurrences || 1)],
["First", ago(f.created_at)],
Expand Down
2 changes: 1 addition & 1 deletion dashboard/app/(product)/app/findings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,7 @@ export default async function Issues({ searchParams }: { searchParams: Promise<S
<span className="sub">{findingTypeInfo(f.type).label}</span>
</td>
<td>
{f.agent_slug ? <Link style={{ whiteSpace: "nowrap" }} href={`/app/agents/${encodeURIComponent(f.agent_slug)}`}>{f.agent_slug}</Link> : <span className="k-muted">—</span>}
{f.agent_slug ? <Link style={{ whiteSpace: "nowrap" }} href={`/app/projects/${encodeURIComponent(f.agent_slug)}`}>{f.agent_slug}</Link> : <span className="k-muted">—</span>}
</td>
<td className="num">{num(f.occurrences || 1)}</td>
<td className="tight muted">{ago(f.last_seen_at || f.created_at)}</td>
Expand Down
4 changes: 2 additions & 2 deletions dashboard/app/(product)/app/glossary/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -188,7 +188,7 @@ export default function Glossary() {
optionally a required human approval, and an expiry. Default deny: an
agent with no grant for a tool cannot call it. Made with{" "}
<span className="mono">agentfox permit grant</span>, explained on
the <a href="/app/agents">Agents page</a>.
the <a href="/app/projects">Agents page</a>.
</td>
</tr>
<tr id="obligation" data-term="obligation">
Expand Down Expand Up @@ -248,7 +248,7 @@ export default function Glossary() {
<strong>capability grant</strong> that decides whether this agent may
call this tool at all, and the <strong>impact tier</strong> declared on
the tool that says how much damage it could do. See{" "}
<a href="/app/policies">Policies</a> and <a href="/app/agents">Agents</a>.
<a href="/app/policies">Policies</a> and <a href="/app/projects">Projects</a>.
</td>
</tr>
<tr id="impact-tier" data-term="impact tier">
Expand Down
77 changes: 36 additions & 41 deletions dashboard/app/(product)/app/layout.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,10 @@ import { Wordmark } from "@/components/product/Logo";
import { AccountMenu } from "@/components/product/AccountMenu";
import { NotificationsBell } from "@/components/product/NotificationsBell";
import { CommandSearch } from "@/components/product/CommandSearch";
import { SideNav, type NavItem } from "@/components/product/SideNav";
import { SideNav } from "@/components/product/SideNav";
import { SetupBanner } from "@/components/product/SetupBanner";
import { WORKSPACE_NAV } from "@/lib/product/nav";
import { PROJECT_COOKIE } from "@/lib/product/project";
import { TopbarStats } from "@/components/product/TopbarStats";
import { getEdition } from "@/lib/product/edition";

Expand All @@ -32,42 +35,6 @@ import { getEdition } from "@/lib/product/edition";
* redirects a signed-out request for a private path to /login, so this is the
* belt to that braces, not the gate.
*/
/**
* The sidebar is the customer's jobs, in the order they reach for them: is
* anything wrong (Home), what is happening (Observe), what is broken (Issues),
* which agent (Agents), what is allowed (Policies), what needs me (Approvals),
* does it hold up (Test), show others (Reports). Setup sits at the bottom.
*
* A row lists the other pages it stands for, so it stays lit on all of them.
*/
const NAV: { group: string; items: NavItem[] }[] = [
{
group: "",
items: [
["Home", "/app"],
["Observe", "/app/observe", ["/app/traces"]],
["Issues", "/app/findings"],
["Agents", "/app/agents"],
["Policies", "/app/policies"],
["Map", "/app/map"],
["Approvals", "/app/approvals"],
["Test", "/app/test", ["/app/evals"]],
["Reports", "/app/reports", ["/app/compliance", "/app/coverage"]],
],
},
{
group: "Setup",
items: [
["Get started", "/app/start"],
[
"Settings",
"/app/settings",
["/app/start?tab=connect", "/app/start?tab=tokens", "/app/sources", "/app/entitlement"],
],
],
},
];

/**
* Destinations that used to be their own sidebar item before the nav was
* consolidated into tabs — findable by name in search even though the
Expand Down Expand Up @@ -97,7 +64,7 @@ const NAV_SEARCH_ONLY: { label: string; href: string; group: string; feature?: s
{ label: "Glossary", href: "/app/glossary", group: "Reference" },
];

const NAV_FLAT: { label: string; href: string; group: string; feature?: string }[] = NAV.flatMap(
const NAV_FLAT: { label: string; href: string; group: string; feature?: string }[] = WORKSPACE_NAV.flatMap(
({ group, items }) => items.map(([label, href]) => ({ label, href, group: group || "Home" })),
).concat(NAV_SEARCH_ONLY);
/**
Expand All @@ -122,8 +89,27 @@ async function TopbarData() {
);
}

/**
* The sidebar, with the projects its switcher offers. In its own Suspense
* boundary for the same reason as the topbar; until the list answers, the
* sidebar is already there in the scope the cookie names.
*/
async function SideNavData({ initial }: { initial: string | null }) {
const { agents } = await safeApi<any>("/api/agents", { agents: [] });
const projects = (agents || []).map((a: any) => ({ slug: a.slug, name: a.name || a.slug, status: a.status }));
return <SideNav projects={projects} initial={initial} />;
}

/** The setup banner, with the workspace checklist it starts from. */
async function SetupBannerData({ project }: { project: string | null }) {
const onboarding = await safeApi<any>("/api/onboarding", null);
return <SetupBanner onboarding={onboarding} initialProject={project} />;
}

export default async function AppLayout({ children }: { children: React.ReactNode }) {
const signedIn = Boolean((await cookies()).get(SESSION_COOKIE)?.value);
const jar = await cookies();
const signedIn = Boolean(jar.get(SESSION_COOKIE)?.value);
const project = jar.get(PROJECT_COOKIE)?.value || null;

return (
<div className="shell">
Expand All @@ -136,7 +122,9 @@ export default async function AppLayout({ children }: { children: React.ReactNod
<Link href="/app" className="brand" aria-label="Overview">
<Wordmark />
</Link>
<SideNav nav={NAV} />
<Suspense fallback={<SideNav projects={[]} initial={project} />}>
{signedIn ? <SideNavData initial={project} /> : <SideNav projects={[]} initial={null} />}
</Suspense>
<ThemeToggle />
</nav>
<div style={{ flex: 1, minWidth: 0, display: "flex", flexDirection: "column" }}>
Expand All @@ -147,7 +135,14 @@ export default async function AppLayout({ children }: { children: React.ReactNod
</Suspense>
</div>
)}
<main>{children}</main>
<main>
{signedIn && (
<Suspense fallback={null}>
<SetupBannerData project={project} />
</Suspense>
)}
{children}
</main>
</div>
</div>
);
Expand Down
33 changes: 0 additions & 33 deletions dashboard/app/(product)/app/map/page.tsx

This file was deleted.

Loading
Loading