Remove agent builder from processor (#7063)

# Description of Changes
Remove agent builder from processor because we don't intend to implement
it any time soon.
This commit is contained in:
James Brunton
2026-07-17 13:15:35 +00:00
committed by GitHub
parent 398f473316
commit ecddce9944
34 changed files with 5 additions and 2208 deletions
@@ -6136,90 +6136,6 @@ free = "Editor plan"
subscribed = "Processor plan"
unlinked = "Not linked"
[portal.agentBuilder]
bootstrapFromDocument = "Bootstrap from document"
sectionsAriaLabel = "Agent builder sections"
selectorAriaLabel = "Agents"
subtitle = "Design, test and ship the AI agents that classify, extract from and route your documents. Define scenarios, fence tool access, run a golden set, and publish a version."
title = "Agent Builder"
[portal.agentBuilder.bootstrap]
cancel = "Cancel"
dropzoneText = "Choose a sample document (PDF or image)"
lead = "Drop a sample document and we'll propose scenarios and an extraction schema you can refine. Nothing is published until you review it."
submit = "Bootstrap agent"
subtitle = "Seed a new agent from one representative file"
title = "Bootstrap from a document"
[portal.agentBuilder.empty]
description = "Bootstrap an agent from a sample document to seed its scenarios and extraction schema, then refine and publish."
title = "No agents yet"
[portal.agentBuilder.evals]
casesPassing = "Cases passing"
columnCase = "Eval case"
columnLatency = "Latency"
columnResult = "Result"
fail = "fail"
goldenSetPassRate = "Golden-set pass rate"
latencyMs = "{{ms}} ms"
notRun = "not run"
pass = "pass"
passRate = "Pass rate"
runEvals = "Run evals"
[portal.agentBuilder.evals.empty]
description = "Evals turn your scenarios into a repeatable golden set. Upgrade to capture pass-rate over time and gate publishes on it."
title = "No golden set yet"
[portal.agentBuilder.kpi]
acrossGoldenSets = "across golden sets"
activeAgents = "Active agents"
avgPassRate = "Avg eval pass rate"
fleetWide = "fleet-wide"
latestPublished = "Latest published"
scenarios = "Scenarios"
testCases = "test cases"
totalDescription_one = "{{count}} total"
totalDescription_other = "{{count}} total"
[portal.agentBuilder.scenarios]
add = "Add"
addScenario = "Add scenario"
enable = "Enable"
expectationLabel = "Expected behaviour"
expectationPlaceholder = "What the agent should do"
inEval = "in eval"
mute = "Mute"
muted = "muted"
nameLabel = "Name"
namePlaceholder = "e.g. Compliance escalation"
[portal.agentBuilder.status]
draft = "Draft"
published = "Published"
[portal.agentBuilder.tabs]
evals = "Evals"
scenarios = "Scenarios"
tools = "Tools"
versions = "Versions"
[portal.agentBuilder.tools]
broadAccess = "Broad access"
deniedHint = "Selected tools are blocked. Everything else stays callable."
deniedTools = "Denied tools"
governanceGate = "Tool governance is available on the Enterprise plan."
restricted = "Restricted"
restrictedAccess = "Restricted tool access"
restrictedDescription = "Allow every tool except the ones you deny below."
[portal.agentBuilder.versions]
current = "current"
historyGate = "Full version history and rollback are available on the Enterprise plan."
publish = "Publish"
rollBack = "Roll back"
[portal.assistant]
close = "Close assistant"
error = "Couldn't reach the assistant."
@@ -7988,7 +7904,6 @@ subtitle = "Reusable input connections that feed documents into Stirling. Config
title = "Sources"
[portal.sources.actions]
agentBuilder = "Agent Builder"
connectSource = "Connect source"
[portal.sources.builder]
@@ -1,10 +0,0 @@
import { describe, expect, it } from "vitest";
import { render } from "@testing-library/react";
import { AgentBuilderAction } from "@portal/components/sources/AgentBuilderAction";
describe("AgentBuilderAction (SaaS)", () => {
it("renders nothing — Agent Builder is hidden pre-release", () => {
const { container } = render(<AgentBuilderAction />);
expect(container).toBeEmptyDOMElement();
});
});
@@ -1,7 +0,0 @@
/**
* SaaS pre-release: Agent Builder isn't shipped yet, so its Sources-header entry
* point is hidden.
*/
export function AgentBuilderAction() {
return null;
}
@@ -1,10 +0,0 @@
import { Navigate } from "react-router-dom";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
/**
* SaaS pre-release: Agent Builder isn't shipped yet and its Sources entry point is
* hidden, so redirect any /agent-builder deep link back to Home.
*/
export function AgentBuilder() {
return <Navigate to={toPortalPath(VIEW_PATHS.home)} replace />;
}
-1
View File
@@ -62,7 +62,6 @@ non-2xx). Views consume via `useAsync()` + `useSectionFlags()` (`hooks/useAsync.
| Documents | `GET /v1/documents` | `tier` | `fetchDocuments` | `DocumentsResponse` |
| Pipelines | `GET /v1/pipelines` · `POST /v1/pipelines/:id/promote-to-policy` | `tier` | `fetchPipelines` · `promoteToPolicy` | `PipelinesResponse` |
| Policies | `GET/POST /api/v1/policies` · `GET/DELETE /api/v1/policies/{id}` · `POST /api/v1/policies/{id}/run` | — | `fetchPolicies` · `savePolicy` · `deletePolicy` · `runPolicy` | `PoliciesResponse` · `Policy` |
| Agent Builder | `GET /v1/agents` | `tier` | `fetchAgents` | `AgentsResponse` |
| Sources | `GET /v1/sources` | `tier` | `fetchSources` | `SourcesResponse` |
| Components | `GET /v1/components` | `tier` | `fetchComponents` | `ComponentsResponse` |
| Infrastructure | `GET /v1/infrastructure/deployments` | `tier` | `fetchDeployments` | `DeploymentsResponse` |
@@ -7,7 +7,6 @@ import { Pipelines } from "@portal/views/Pipelines";
import { PipelineBuilder } from "@portal/views/PipelineBuilder";
import { Sources } from "@portal/views/Sources";
import { SourceBuilder } from "@portal/views/SourceBuilder";
import { AgentBuilder } from "@portal/views/AgentBuilder";
import { Policies } from "@portal/views/Policies";
import { EditorAdmin } from "@portal/views/EditorAdmin";
import { Infrastructure } from "@portal/views/Infrastructure";
@@ -51,10 +50,6 @@ export function ViewRouter() {
path={`${rel(VIEW_PATHS.sources)}/:id`}
element={<SourceBuilder />}
/>
<Route
path={rel(VIEW_PATHS["agent-builder"])}
element={<AgentBuilder />}
/>
<Route path={rel(VIEW_PATHS.policies)} element={<Policies />} />
<Route path={rel(VIEW_PATHS.documents)} element={<Documents />} />
<Route path={rel(VIEW_PATHS.editor)} element={<EditorAdmin />} />
-132
View File
@@ -1,132 +0,0 @@
import { apiClient } from "@portal/api/http";
import type { Tier } from "@portal/contexts/TierContext";
/*
* An "agent" here is an AI agent that classifies, extracts from, and routes
* documents. The builder is its lifecycle surface: scenarios (named test
* cases), tool-access governance, an eval / golden set, and version history.
*/
/* ──────────────────────────────────────────────────────────────────────── */
/* Domain types */
/* ──────────────────────────────────────────────────────────────────────── */
export type AgentStatus = "draft" | "published";
/**
* Tool-access posture. `broad` lets the agent call any tool it can reach;
* `restricted` is allow-by-default minus an explicit deny list (the governance
* mode enterprise tenants use to fence agents away from sensitive tools).
*/
export type ToolMode = "broad" | "restricted";
/** A named test case describing expected agent behaviour for a kind of input. */
export interface Scenario {
id: string;
name: string;
/** What the agent is expected to do for this input. */
expectation: string;
/** Whether this scenario is currently exercised by the eval run. */
enabled: boolean;
}
/** A single golden-set check with its last-run outcome. */
export interface EvalCase {
id: string;
name: string;
/** Last observed pass/fail; null when the case has never been run. */
passing: boolean | null;
/** Mean latency of the last run in milliseconds. */
latencyMs: number;
}
export interface AgentVersion {
/** Display label, e.g. "v3" or "v2-draft". */
version: string;
status: AgentStatus;
/** ISO timestamp the version was created. */
createdAt: string;
author: string;
/** One-line change summary. */
note: string;
}
export interface Agent {
id: string;
name: string;
/** One-line role description shown under the name in the selector. */
role: string;
status: AgentStatus;
/** Current working version, e.g. "v3" or "v2-draft". */
version: string;
model: string;
scenarios: Scenario[];
toolMode: ToolMode;
/** Tools the agent may not call when `toolMode` is "restricted". */
deniedTools: string[];
/** Count of golden-set cases currently passing. */
evalsPassing: number;
/** Total golden-set cases. */
evalsTotal: number;
evalCases: EvalCase[];
versions: AgentVersion[];
}
export interface AgentsSummary {
/** Agents in the "published" state. */
activeAgents: number;
/** Total agents regardless of status. */
totalAgents: number;
/** Mean eval pass-rate across all agents, 0..1. */
avgPassRate: number;
/** Total scenarios across all agents. */
totalScenarios: number;
/** Latest published version label across the fleet, e.g. "v3". */
latestPublished: string;
}
export interface AgentsResponse {
summary: AgentsSummary;
agents: Agent[];
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Presentation metadata (chip tone per status). Product copy, client-side. */
/* ──────────────────────────────────────────────────────────────────────── */
export const AGENT_STATUS_TONE: Record<AgentStatus, "success" | "neutral"> = {
published: "success",
draft: "neutral",
};
export const AGENT_STATUS_LABEL: Record<AgentStatus, string> = {
published: "portal.agentBuilder.status.published",
draft: "portal.agentBuilder.status.draft",
};
/**
* Catalogue of tools an agent can be granted or denied. Surfaced as the chip
* palette in restricted mode so the deny list is picked from a known set
* rather than free-typed.
*/
export const TOOL_CATALOGUE = [
"extract.fields",
"classify.document",
"route.pipeline",
"lookup.crm",
"send.email",
"write.audit",
"read.pii",
"invoke.webhook",
] as const;
/* ──────────────────────────────────────────────────────────────────────── */
/* Endpoints */
/* ──────────────────────────────────────────────────────────────────────── */
/** GET /v1/agents?tier=… — fleet summary + every agent with its full builder state. */
export async function fetchAgents(tier: Tier): Promise<AgentsResponse> {
return apiClient.local.json<AgentsResponse>(
`/v1/agents?tier=${encodeURIComponent(tier)}`,
);
}
@@ -12,7 +12,7 @@ describe("portal demo data seam", () => {
it("is inert until enabled", async () => {
expect(
await resolveDemoResponse(
new URL("/v1/agents?tier=pro", window.location.origin),
new URL("/v1/notifications", window.location.origin),
{},
),
).toBeUndefined();
@@ -22,12 +22,12 @@ describe("portal demo data seam", () => {
it("answers from the fixture handlers while enabled", async () => {
await enablePortalDemoData();
const res = await resolveDemoResponse(
new URL("/v1/agents?tier=pro", window.location.origin),
new URL("/v1/notifications", window.location.origin),
{},
);
expect(res?.status).toBe(200);
const body = (await res?.json()) as { agents: unknown[] };
expect(body.agents.length).toBeGreaterThan(0);
const body = (await res?.json()) as unknown[];
expect(body.length).toBeGreaterThan(0);
});
it("releases back to the network on disable", async () => {
@@ -35,7 +35,7 @@ describe("portal demo data seam", () => {
disablePortalDemoData();
expect(
await resolveDemoResponse(
new URL("/v1/agents?tier=pro", window.location.origin),
new URL("/v1/notifications", window.location.origin),
{},
),
).toBeUndefined();
@@ -1,30 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { agentsFor } from "@portal/mocks/agents";
import { AgentBuilderPanel } from "@portal/components/agent-builder/AgentBuilderPanel";
const PRO = agentsFor("pro");
const meta: Meta<typeof AgentBuilderPanel> = {
title: "Portal/AgentBuilder/AgentBuilderPanel",
component: AgentBuilderPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "52rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof AgentBuilderPanel>;
/** Published agent with enterprise governance unlocked. */
export const Published: Story = {
args: { agent: PRO[0], governanceUnlocked: true },
};
/** Draft agent with governance locked (pro/free posture). */
export const DraftLocked: Story = {
args: { agent: PRO[2], governanceUnlocked: false },
};
@@ -1,86 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { StatusBadge, Tabs, type TabItem } from "@app/ui";
import {
type Agent,
AGENT_STATUS_LABEL,
AGENT_STATUS_TONE,
} from "@portal/api/agents";
import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel";
import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel";
import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel";
import { VersionsPanel } from "@portal/components/agent-builder/VersionsPanel";
import "@portal/views/AgentBuilder.css";
type BuilderTab = "scenarios" | "tools" | "evals" | "versions";
interface AgentBuilderPanelProps {
agent: Agent;
/** Enterprise unlocks restricted-tools governance and deep version history. */
governanceUnlocked: boolean;
}
/** The selected agent's builder: header + tabbed Scenarios / Tools / Evals / Versions. */
export function AgentBuilderPanel({
agent,
governanceUnlocked,
}: AgentBuilderPanelProps) {
const { t } = useTranslation();
const [tab, setTab] = useState<BuilderTab>("scenarios");
const tabs: TabItem<BuilderTab>[] = [
{
key: "scenarios",
label: t("portal.agentBuilder.tabs.scenarios"),
count: agent.scenarios.length,
},
{ key: "tools", label: t("portal.agentBuilder.tabs.tools") },
{
key: "evals",
label: t("portal.agentBuilder.tabs.evals"),
count: agent.evalsTotal > 0 ? agent.evalsTotal : undefined,
},
{
key: "versions",
label: t("portal.agentBuilder.tabs.versions"),
count: agent.versions.length,
},
];
return (
<section className="portal-agents__builder">
<header className="portal-agents__builder-head">
<div>
<h2 className="portal-agents__builder-title">{agent.name}</h2>
<span className="portal-agents__builder-sub">{agent.role}</span>
</div>
<div className="portal-agents__builder-meta">
<StatusBadge tone={AGENT_STATUS_TONE[agent.status]} size="sm">
{t(AGENT_STATUS_LABEL[agent.status])}
</StatusBadge>
<code className="portal-agents__builder-version">
{agent.version}
</code>
<code className="portal-agents__builder-model">{agent.model}</code>
</div>
</header>
<Tabs<BuilderTab>
items={tabs}
activeKey={tab}
onChange={setTab}
variant="underline"
ariaLabel={t("portal.agentBuilder.sectionsAriaLabel")}
/>
{tab === "scenarios" && <ScenariosPanel agent={agent} />}
{tab === "tools" && (
<ToolsPanel agent={agent} governanceUnlocked={governanceUnlocked} />
)}
{tab === "evals" && <EvalsPanel agent={agent} />}
{tab === "versions" && (
<VersionsPanel agent={agent} historyUnlocked={governanceUnlocked} />
)}
</section>
);
}
@@ -1,23 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { buildAgentsResponse } from "@portal/mocks/agents";
import { AgentKpiStrip } from "@portal/components/agent-builder/AgentKpiStrip";
const meta: Meta<typeof AgentKpiStrip> = {
title: "Portal/AgentBuilder/AgentKpiStrip",
component: AgentKpiStrip,
parameters: { layout: "padded" },
};
export default meta;
type Story = StoryObj<typeof AgentKpiStrip>;
export const Pro: Story = {
args: { summary: buildAgentsResponse("pro").summary, loading: false },
};
export const Enterprise: Story = {
args: { summary: buildAgentsResponse("enterprise").summary, loading: false },
};
export const Loading: Story = {
args: { summary: null, loading: true },
};
@@ -1,57 +0,0 @@
import { useTranslation } from "react-i18next";
import { MetricCard, MetricStrip } from "@app/ui";
import type { AgentsSummary } from "@portal/api/agents";
interface AgentKpiStripProps {
summary: AgentsSummary | null;
loading: boolean;
}
export function AgentKpiStrip({ summary, loading }: AgentKpiStripProps) {
const { t } = useTranslation();
/**
* KPI labels are product copy — they describe what each metric IS, not its
* value — so the strip's structure stays stable across loading / ready
* states; only values flow from the API.
*/
const kpiLabels = [
t("portal.agentBuilder.kpi.activeAgents"),
t("portal.agentBuilder.kpi.avgPassRate"),
t("portal.agentBuilder.kpi.scenarios"),
t("portal.agentBuilder.kpi.latestPublished"),
] as const;
const values: (string | number)[] = summary
? [
summary.activeAgents,
`${Math.round(summary.avgPassRate * 100)}%`,
summary.totalScenarios,
summary.latestPublished,
]
: ["—", "—", "—", "—"];
const descriptions: (string | undefined)[] = summary
? [
t("portal.agentBuilder.kpi.totalDescription", {
count: summary.totalAgents,
}),
t("portal.agentBuilder.kpi.acrossGoldenSets"),
t("portal.agentBuilder.kpi.testCases"),
t("portal.agentBuilder.kpi.fleetWide"),
]
: [];
return (
<MetricStrip>
{kpiLabels.map((label, i) => (
<MetricCard
key={label}
label={label}
value={loading ? "—" : values[i]}
description={loading ? undefined : descriptions[i]}
/>
))}
</MetricStrip>
);
}
@@ -1,33 +0,0 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { agentsFor } from "@portal/mocks/agents";
import { AgentSelector } from "@portal/components/agent-builder/AgentSelector";
const AGENTS = agentsFor("enterprise");
const meta: Meta<typeof AgentSelector> = {
title: "Portal/AgentBuilder/AgentSelector",
component: AgentSelector,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "18rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof AgentSelector>;
export const Default: Story = {
args: { agents: AGENTS, selectedId: AGENTS[0].id, onSelect: () => {} },
};
/** Clicking a row moves the selection — drives which builder is shown. */
export const Interactive: Story = {
render: () => {
const [id, setId] = useState(AGENTS[0].id);
return <AgentSelector agents={AGENTS} selectedId={id} onSelect={setId} />;
},
};
@@ -1,55 +0,0 @@
import { useTranslation } from "react-i18next";
import {
type Agent,
AGENT_STATUS_LABEL,
AGENT_STATUS_TONE,
} from "@portal/api/agents";
import { Button, StatusBadge } from "@app/ui";
import "@portal/views/AgentBuilder.css";
interface AgentSelectorProps {
agents: Agent[];
selectedId: string | null;
onSelect: (id: string) => void;
}
/** Left-rail list of agents; the selected row drives which builder is shown. */
export function AgentSelector({
agents,
selectedId,
onSelect,
}: AgentSelectorProps) {
const { t } = useTranslation();
return (
<nav
className="portal-agents__selector"
aria-label={t("portal.agentBuilder.selectorAriaLabel")}
>
{agents.map((a) => (
<Button
key={a.id}
variant="tertiary"
justify="start"
fullWidth
className={
"portal-agents__selector-item" +
(a.id === selectedId ? " is-selected" : "")
}
aria-current={a.id === selectedId}
onClick={() => onSelect(a.id)}
>
<span className="portal-agents__selector-main">
<strong className="portal-agents__selector-name">{a.name}</strong>
<span className="portal-agents__selector-role">{a.role}</span>
</span>
<span className="portal-agents__selector-meta">
<StatusBadge tone={AGENT_STATUS_TONE[a.status]} size="sm">
{t(AGENT_STATUS_LABEL[a.status])}
</StatusBadge>
<code className="portal-agents__selector-version">{a.version}</code>
</span>
</Button>
))}
</nav>
);
}
@@ -1,30 +0,0 @@
import { useState } from "react";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Button } from "@app/ui";
import { BootstrapDialog } from "@portal/components/agent-builder/BootstrapDialog";
const meta: Meta<typeof BootstrapDialog> = {
title: "Portal/AgentBuilder/BootstrapDialog",
component: BootstrapDialog,
parameters: { layout: "fullscreen" },
};
export default meta;
type Story = StoryObj<typeof BootstrapDialog>;
/** Open by default so the dialog is visible in the canvas. */
export const Open: Story = {
args: { open: true, onClose: () => {} },
};
/** Toggled from a trigger, mirroring how the view drives it. */
export const Triggered: Story = {
render: () => {
const [open, setOpen] = useState(false);
return (
<div style={{ padding: "1.5rem" }}>
<Button onClick={() => setOpen(true)}>Bootstrap from document</Button>
<BootstrapDialog open={open} onClose={() => setOpen(false)} />
</div>
);
},
};
@@ -1,71 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import FileUploadRounded from "@mui/icons-material/FileUploadRounded";
import { Button, Modal } from "@app/ui";
import "@portal/views/AgentBuilder.css";
interface BootstrapDialogProps {
open: boolean;
onClose: () => void;
}
/**
* Seed a new agent from a sample document — the user drops one representative
* file and the backend proposes scenarios and an extraction schema. Demo stub:
* it captures the chosen file name locally and closes without provisioning.
*/
export function BootstrapDialog({ open, onClose }: BootstrapDialogProps) {
const { t } = useTranslation();
const [fileName, setFileName] = useState<string | null>(null);
function close() {
onClose();
setTimeout(() => setFileName(null), 200);
}
function bootstrap() {
// TODO(backend): POST /v1/agents/bootstrap (multipart sample document) —
// infer a starter agent (scenarios + extraction schema) from the file.
close();
}
return (
<Modal
open={open}
onClose={close}
width="md"
title={t("portal.agentBuilder.bootstrap.title")}
subtitle={t("portal.agentBuilder.bootstrap.subtitle")}
footer={
<div className="portal-agents__dialog-footer">
<Button variant="tertiary" size="sm" onClick={close}>
{t("portal.agentBuilder.bootstrap.cancel")}
</Button>
<Button size="sm" onClick={bootstrap} disabled={!fileName}>
{t("portal.agentBuilder.bootstrap.submit")}
</Button>
</div>
}
>
<div className="portal-agents__bootstrap">
<p className="portal-agents__bootstrap-lead">
{t("portal.agentBuilder.bootstrap.lead")}
</p>
<label className="portal-agents__dropzone">
<input
type="file"
accept=".pdf,.png,.jpg,.jpeg,.tiff"
className="portal-agents__dropzone-input"
onChange={(e) => setFileName(e.target.files?.[0]?.name ?? null)}
/>
<span className="portal-agents__dropzone-icon" aria-hidden>
<FileUploadRounded style={{ fontSize: "1.5rem" }} />
</span>
<span className="portal-agents__dropzone-text">
{fileName ?? t("portal.agentBuilder.bootstrap.dropzoneText")}
</span>
</label>
</div>
</Modal>
);
}
@@ -1,36 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { agentsFor } from "@portal/mocks/agents";
import { EvalsPanel } from "@portal/components/agent-builder/EvalsPanel";
const PRO = agentsFor("pro");
const FREE = agentsFor("free");
const meta: Meta<typeof EvalsPanel> = {
title: "Portal/AgentBuilder/EvalsPanel",
component: EvalsPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof EvalsPanel>;
/** High pass-rate agent — bar and pass-rate tile read green. */
export const Passing: Story = {
args: { agent: PRO[0] },
};
/** KYC draft sits below the green band, so the pass-rate tile warns. */
export const BelowTarget: Story = {
args: { agent: PRO[2] },
};
/** Free-tier agent has no golden set — the panel shows the upgrade gate. */
export const NoGoldenSet: Story = {
args: { agent: FREE[0] },
};
@@ -1,113 +0,0 @@
import { useTranslation } from "react-i18next";
import {
Button,
EmptyState,
ProgressBar,
StatTile,
StatusBadge,
Table,
type TableColumn,
} from "@app/ui";
import type { Agent, EvalCase } from "@portal/api/agents";
import "@portal/views/AgentBuilder.css";
interface EvalsPanelProps {
agent: Agent;
}
/** Golden-set pass-rate, the per-case results table, and a run affordance. */
export function EvalsPanel({ agent }: EvalsPanelProps) {
const { t } = useTranslation();
const columns: TableColumn<EvalCase>[] = [
{
key: "name",
header: t("portal.agentBuilder.evals.columnCase"),
render: (c) => c.name,
},
{
key: "result",
header: t("portal.agentBuilder.evals.columnResult"),
render: (c) =>
c.passing === null ? (
<span className="portal-agents__muted">
{t("portal.agentBuilder.evals.notRun")}
</span>
) : (
<StatusBadge tone={c.passing ? "success" : "danger"} size="sm">
{c.passing
? t("portal.agentBuilder.evals.pass")
: t("portal.agentBuilder.evals.fail")}
</StatusBadge>
),
},
{
key: "latency",
header: t("portal.agentBuilder.evals.columnLatency"),
align: "right",
render: (c) => (
<span className="portal-agents__mono">
{t("portal.agentBuilder.evals.latencyMs", { ms: c.latencyMs })}
</span>
),
},
];
if (agent.evalsTotal === 0) {
return (
<div className="portal-agents__panel">
<EmptyState
title={t("portal.agentBuilder.evals.empty.title")}
description={t("portal.agentBuilder.evals.empty.description")}
size="compact"
/>
</div>
);
}
const rate = agent.evalsPassing / agent.evalsTotal;
function runEvals() {
// TODO(backend): POST /v1/agents/{id}/evals/run — kick off a golden-set
// run, then poll for the updated case results.
}
return (
<div className="portal-agents__panel">
<div className="portal-agents__eval-head">
<div className="portal-agents__stat-grid portal-agents__stat-grid--two">
<StatTile
label={t("portal.agentBuilder.evals.passRate")}
value={`${Math.round(rate * 100)}%`}
tone={rate >= 0.95 ? "success" : rate >= 0.8 ? "warning" : "danger"}
/>
<StatTile
label={t("portal.agentBuilder.evals.casesPassing")}
value={`${agent.evalsPassing} / ${agent.evalsTotal}`}
/>
</div>
<Button size="sm" variant="secondary" onClick={runEvals}>
{t("portal.agentBuilder.evals.runEvals")}
</Button>
</div>
<div className="portal-agents__bar-row">
<div className="portal-agents__bar-head">
<span>{t("portal.agentBuilder.evals.goldenSetPassRate")}</span>
<strong>{Math.round(rate * 100)}%</strong>
</div>
<ProgressBar
value={rate}
color={rate >= 0.95 ? "var(--color-green)" : "var(--color-amber)"}
label={t("portal.agentBuilder.evals.goldenSetPassRate")}
/>
</div>
<Table<EvalCase>
columns={columns}
rows={agent.evalCases}
rowKey={(c) => c.id}
/>
</div>
);
}
@@ -1,30 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { agentsFor } from "@portal/mocks/agents";
import { ScenariosPanel } from "@portal/components/agent-builder/ScenariosPanel";
const AGENTS = agentsFor("pro");
const meta: Meta<typeof ScenariosPanel> = {
title: "Portal/AgentBuilder/ScenariosPanel",
component: ScenariosPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ScenariosPanel>;
/** Contract Router ships three scenarios; the add-row stages a fourth locally. */
export const Default: Story = {
args: { agent: AGENTS[0] },
};
/** An agent with a muted scenario (excluded from the eval run). */
export const WithMutedScenario: Story = {
args: { agent: AGENTS[1] },
};
@@ -1,112 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Button, Chip, FormField, Input, StatusBadge } from "@app/ui";
import type { Agent, Scenario } from "@portal/api/agents";
import "@portal/views/AgentBuilder.css";
interface ScenariosPanelProps {
agent: Agent;
}
/**
* Named test cases describing expected behaviour. Edits live in local state —
* the surface is mock-driven, so adding a row only stages it client-side until
* the submit endpoint exists.
*/
export function ScenariosPanel({ agent }: ScenariosPanelProps) {
const { t } = useTranslation();
// Seed from the agent and re-seed when the selection changes (key prop on the
// builder forces a remount, so a plain useState initialiser is enough).
const [scenarios, setScenarios] = useState<Scenario[]>(agent.scenarios);
const [name, setName] = useState("");
const [expectation, setExpectation] = useState("");
const canAdd = name.trim() !== "" && expectation.trim() !== "";
function addScenario() {
if (!canAdd) return;
const next: Scenario = {
id: `sc-local-${Date.now()}`,
name: name.trim(),
expectation: expectation.trim(),
enabled: true,
};
// TODO(backend): POST /v1/agents/{id}/scenarios { name, expectation } —
// persist the scenario, then replace the optimistic row with the response.
setScenarios((cur) => [...cur, next]);
setName("");
setExpectation("");
}
function toggleEnabled(id: string) {
setScenarios((cur) =>
cur.map((s) => (s.id === id ? { ...s, enabled: !s.enabled } : s)),
);
}
return (
<div className="portal-agents__panel">
<ul className="portal-agents__scenarios">
{scenarios.map((s) => (
<li key={s.id} className="portal-agents__scenario">
<div className="portal-agents__scenario-text">
<div className="portal-agents__scenario-head">
<strong>{s.name}</strong>
<StatusBadge
tone={s.enabled ? "success" : "neutral"}
size="sm"
showDot={false}
>
{s.enabled
? t("portal.agentBuilder.scenarios.inEval")
: t("portal.agentBuilder.scenarios.muted")}
</StatusBadge>
</div>
<span className="portal-agents__scenario-expect">
{s.expectation}
</span>
</div>
<Button
size="sm"
variant="tertiary"
onClick={() => toggleEnabled(s.id)}
>
{s.enabled
? t("portal.agentBuilder.scenarios.mute")
: t("portal.agentBuilder.scenarios.enable")}
</Button>
</li>
))}
</ul>
<div className="portal-agents__scenario-add">
<Chip accent="default" size="sm">
{t("portal.agentBuilder.scenarios.addScenario")}
</Chip>
<div className="portal-agents__scenario-form">
<FormField label={t("portal.agentBuilder.scenarios.nameLabel")}>
<Input
value={name}
onChange={(e) => setName(e.target.value)}
placeholder={t("portal.agentBuilder.scenarios.namePlaceholder")}
/>
</FormField>
<FormField
label={t("portal.agentBuilder.scenarios.expectationLabel")}
>
<Input
value={expectation}
onChange={(e) => setExpectation(e.target.value)}
placeholder={t(
"portal.agentBuilder.scenarios.expectationPlaceholder",
)}
/>
</FormField>
<Button size="sm" onClick={addScenario} disabled={!canAdd}>
{t("portal.agentBuilder.scenarios.add")}
</Button>
</div>
</div>
</div>
);
}
@@ -1,35 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { agentsFor } from "@portal/mocks/agents";
import { ToolsPanel } from "@portal/components/agent-builder/ToolsPanel";
const AGENTS = agentsFor("enterprise");
const meta: Meta<typeof ToolsPanel> = {
title: "Portal/AgentBuilder/ToolsPanel",
component: ToolsPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof ToolsPanel>;
/** Restricted mode with a deny list — the enterprise governance posture. */
export const Restricted: Story = {
args: { agent: AGENTS[0], governanceUnlocked: true },
};
/** Broad access — every tool callable. */
export const Broad: Story = {
args: { agent: AGENTS[1], governanceUnlocked: true },
};
/** Governance locked: the toggle is disabled and explains the upgrade. */
export const GovernanceLocked: Story = {
args: { agent: AGENTS[0], governanceUnlocked: false },
};
@@ -1,87 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import { Chip, ToggleSwitch } from "@app/ui";
import { type Agent, type ToolMode, TOOL_CATALOGUE } from "@portal/api/agents";
import "@portal/views/AgentBuilder.css";
interface ToolsPanelProps {
agent: Agent;
/** Restricted-tools governance is an enterprise capability. */
governanceUnlocked: boolean;
}
/**
* Tool-access posture. `broad` grants every tool; `restricted` is allow-by-
* default minus an explicit deny list, picked from the known tool catalogue.
*/
export function ToolsPanel({ agent, governanceUnlocked }: ToolsPanelProps) {
const { t } = useTranslation();
const [mode, setMode] = useState<ToolMode>(agent.toolMode);
const [denied, setDenied] = useState<string[]>(agent.deniedTools);
function setRestricted(on: boolean) {
// TODO(backend): PATCH /v1/agents/{id}/tools { mode, deniedTools } —
// persist the access posture.
setMode(on ? "restricted" : "broad");
}
function toggleDenied(tool: string) {
setDenied((cur) =>
cur.includes(tool) ? cur.filter((t) => t !== tool) : [...cur, tool],
);
}
const restricted = mode === "restricted";
return (
<div className="portal-agents__panel">
<div className="portal-agents__tool-mode">
<ToggleSwitch
checked={restricted}
onChange={setRestricted}
disabled={!governanceUnlocked}
label={t("portal.agentBuilder.tools.restrictedAccess")}
description={
governanceUnlocked
? t("portal.agentBuilder.tools.restrictedDescription")
: t("portal.agentBuilder.tools.governanceGate")
}
/>
<Chip accent={restricted ? "warning" : "success"} size="sm">
{restricted
? t("portal.agentBuilder.tools.restricted")
: t("portal.agentBuilder.tools.broadAccess")}
</Chip>
</div>
{restricted && (
<div className="portal-agents__detail-section">
<span className="portal-agents__detail-heading">
{t("portal.agentBuilder.tools.deniedTools")}
</span>
<p className="portal-agents__hint">
{t("portal.agentBuilder.tools.deniedHint")}
</p>
<div className="portal-agents__chips">
{TOOL_CATALOGUE.map((tool) => {
const isDenied = denied.includes(tool);
return (
<Chip
key={tool}
accent={isDenied ? "danger" : "neutral"}
size="sm"
onClick={
governanceUnlocked ? () => toggleDenied(tool) : undefined
}
>
{isDenied ? "✕ " : ""}
{tool}
</Chip>
);
})}
</div>
</div>
)}
</div>
);
}
@@ -1,35 +0,0 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { agentsFor } from "@portal/mocks/agents";
import { VersionsPanel } from "@portal/components/agent-builder/VersionsPanel";
const PRO = agentsFor("pro");
const meta: Meta<typeof VersionsPanel> = {
title: "Portal/AgentBuilder/VersionsPanel",
component: VersionsPanel,
parameters: { layout: "padded" },
decorators: [
(S) => (
<div style={{ maxWidth: "44rem" }}>
<S />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof VersionsPanel>;
/** Full history with rollback on prior published versions. */
export const FullHistory: Story = {
args: { agent: PRO[0], historyUnlocked: true },
};
/** A draft current version offers a publish action. */
export const DraftAwaitingPublish: Story = {
args: { agent: PRO[2], historyUnlocked: true },
};
/** History locked: only the current version shows, with an upgrade hint. */
export const HistoryLocked: Story = {
args: { agent: PRO[0], historyUnlocked: false },
};
@@ -1,104 +0,0 @@
import { useTranslation } from "react-i18next";
import { Button, StatusBadge } from "@app/ui";
import {
type Agent,
AGENT_STATUS_LABEL,
AGENT_STATUS_TONE,
} from "@portal/api/agents";
import "@portal/views/AgentBuilder.css";
interface VersionsPanelProps {
agent: Agent;
/** Deep version history is an enterprise capability; lower tiers see the gate. */
historyUnlocked: boolean;
}
/** Render an ISO timestamp as a short, locale-stable date. */
function formatDate(iso: string): string {
return new Date(iso).toLocaleDateString(undefined, {
year: "numeric",
month: "short",
day: "numeric",
});
}
/** Version history with publish / rollback actions per row. */
export function VersionsPanel({ agent, historyUnlocked }: VersionsPanelProps) {
const { t } = useTranslation();
// Without governance, only the current version is meaningful to show.
const versions = historyUnlocked
? agent.versions
: agent.versions.slice(0, 1);
const publishedExists = agent.versions.some((v) => v.status === "published");
function publish(version: string) {
void version;
// TODO(backend): POST /v1/agents/{id}/versions/{v}/publish — promote the
// draft to published and demote the previous published version.
}
function rollback(version: string) {
void version;
// TODO(backend): POST /v1/agents/{id}/versions/{v}/rollback — re-publish a
// prior version as the active one.
}
return (
<div className="portal-agents__panel">
<ol className="portal-agents__versions">
{versions.map((v) => {
const isCurrent = v.version === agent.version;
return (
<li key={v.version} className="portal-agents__version">
<div className="portal-agents__version-main">
<div className="portal-agents__version-head">
<code className="portal-agents__version-tag">
{v.version}
</code>
<StatusBadge tone={AGENT_STATUS_TONE[v.status]} size="sm">
{t(AGENT_STATUS_LABEL[v.status])}
</StatusBadge>
{isCurrent && (
<StatusBadge tone="info" size="sm" showDot={false}>
{t("portal.agentBuilder.versions.current")}
</StatusBadge>
)}
</div>
<span className="portal-agents__version-note">{v.note}</span>
<span className="portal-agents__version-meta">
{formatDate(v.createdAt)} · {v.author}
</span>
</div>
<div className="portal-agents__version-actions">
{v.status === "draft" && (
<Button
size="sm"
variant="secondary"
onClick={() => publish(v.version)}
>
{t("portal.agentBuilder.versions.publish")}
</Button>
)}
{v.status === "published" && !isCurrent && (
<Button
size="sm"
variant="tertiary"
onClick={() => rollback(v.version)}
>
{t("portal.agentBuilder.versions.rollBack")}
</Button>
)}
</div>
</li>
);
})}
</ol>
{!historyUnlocked && publishedExists && (
<p className="portal-agents__hint">
{t("portal.agentBuilder.versions.historyGate")}
</p>
)}
</div>
);
}
@@ -221,18 +221,6 @@ export function PoliciesIcon(props: IconProps) {
);
}
export function AgentBuilderIcon(props: IconProps) {
return (
<Svg {...props}>
<rect x="3" y="11" width="18" height="10" rx="2" />
<circle cx="12" cy="5" r="2" />
<path d="M12 7v4" />
<line x1="8" y1="16" x2="8" y2="16" />
<line x1="16" y1="16" x2="16" y2="16" />
</Svg>
);
}
export function ProcurementIcon(props: IconProps) {
return (
<Svg {...props}>
@@ -1,22 +0,0 @@
import { useTranslation } from "react-i18next";
import { Button } from "@app/ui";
import { useView } from "@portal/contexts/ViewContext";
import { AgentBuilderIcon } from "@portal/components/icons";
/**
* The "Agent Builder" action in the Sources header. A flavor seam: the SaaS build
* shadows this with a no-op (Agent Builder isn't shipped there yet).
*/
export function AgentBuilderAction() {
const { t } = useTranslation();
const { setActiveView } = useView();
return (
<Button
variant="secondary"
onClick={() => setActiveView("agent-builder")}
leftSection={<AgentBuilderIcon size={16} />}
>
{t("portal.sources.actions.agentBuilder")}
</Button>
);
}
@@ -7,7 +7,6 @@ export type ViewId =
| "editor"
| "users"
| "sources"
| "agent-builder"
| "policies"
| "pipelines"
| "documents"
@@ -22,7 +21,6 @@ export const VIEW_LABELS: Record<ViewId, string> = {
editor: "Editor",
users: "Users",
sources: "Sources",
"agent-builder": "Agent Builder",
policies: "Policies",
pipelines: "Pipelines",
documents: "Documents",
@@ -38,7 +36,6 @@ export const VIEW_PATHS: Record<ViewId, string> = {
editor: "/editor",
users: "/users",
sources: "/sources",
"agent-builder": "/agent-builder",
policies: "/policies",
pipelines: "/pipelines",
documents: "/documents",
-399
View File
@@ -1,399 +0,0 @@
/**
* Agent Builder fixtures. Types live in api/agents.ts (the backend contract);
* this module only builds fake data for Storybook and tests.
*
* An "agent" here is an AI agent that classifies, extracts from, and routes
* documents. The builder is its lifecycle surface: scenarios (named test
* cases), tool-access governance, an eval / golden set, and version history.
*/
import type { Tier } from "@portal/contexts/TierContext";
import type { Agent, AgentsResponse, AgentsSummary } from "@portal/api/agents";
/* ──────────────────────────────────────────────────────────────────────── */
/* Fixture builders */
/* ──────────────────────────────────────────────────────────────────────── */
const CONTRACT_ROUTER: Agent = {
id: "agent-contract-router",
name: "Contract Router",
role: "Classifies inbound contracts and routes to the right review queue",
status: "published",
version: "v3",
model: "claude-opus-4.1",
toolMode: "restricted",
deniedTools: ["send.email", "read.pii"],
evalsPassing: 23,
evalsTotal: 24,
scenarios: [
{
id: "sc-cr-1",
name: "New customer file",
expectation: "Route to onboarding queue and tag as net-new account",
enabled: true,
},
{
id: "sc-cr-2",
name: "Renewal with price change",
expectation: "Flag the delta and route to legal for redline review",
enabled: true,
},
{
id: "sc-cr-3",
name: "Non-standard liability clause",
expectation: "Escalate to senior counsel rather than auto-approving",
enabled: true,
},
],
evalCases: [
{
id: "ec-cr-1",
name: "MSA — standard terms",
passing: true,
latencyMs: 1840,
},
{
id: "ec-cr-2",
name: "MSA — capped liability",
passing: true,
latencyMs: 2010,
},
{
id: "ec-cr-3",
name: "Order form — multi-year",
passing: true,
latencyMs: 1620,
},
{
id: "ec-cr-4",
name: "DPA — cross-border transfer",
passing: false,
latencyMs: 2440,
},
],
versions: [
{
version: "v3",
status: "published",
createdAt: "2026-05-28T14:12:00Z",
author: "legal-ops@acme.com",
note: "Added cross-border DPA detection",
},
{
version: "v2",
status: "published",
createdAt: "2026-04-09T09:30:00Z",
author: "legal-ops@acme.com",
note: "Tightened renewal price-change routing",
},
{
version: "v1",
status: "published",
createdAt: "2026-02-15T16:45:00Z",
author: "you@acme.com",
note: "Initial publish",
},
],
};
const INVOICE_AGENT: Agent = {
id: "agent-invoice",
name: "Invoice Agent",
role: "Extracts line items and routes invoices into the AP pipeline",
status: "published",
version: "v5",
model: "claude-sonnet-4.5",
toolMode: "broad",
deniedTools: [],
evalsPassing: 31,
evalsTotal: 32,
scenarios: [
{
id: "sc-inv-1",
name: "Multi-page invoice",
expectation: "Extract all line items across pages into one record",
enabled: true,
},
{
id: "sc-inv-2",
name: "Foreign currency total",
expectation: "Preserve original currency and attach FX note",
enabled: true,
},
{
id: "sc-inv-3",
name: "Missing PO number",
expectation: "Route to manual matching rather than auto-posting",
enabled: false,
},
],
evalCases: [
{ id: "ec-inv-1", name: "Single-page USD", passing: true, latencyMs: 980 },
{ id: "ec-inv-2", name: "Multi-page EUR", passing: true, latencyMs: 1320 },
{
id: "ec-inv-3",
name: "Handwritten total",
passing: true,
latencyMs: 1510,
},
{
id: "ec-inv-4",
name: "No PO reference",
passing: false,
latencyMs: 1100,
},
],
versions: [
{
version: "v5",
status: "published",
createdAt: "2026-06-01T11:00:00Z",
author: "platform@acme.com",
note: "FX note extraction for foreign-currency invoices",
},
{
version: "v4",
status: "published",
createdAt: "2026-05-10T08:20:00Z",
author: "platform@acme.com",
note: "Multi-page line-item stitching",
},
],
};
const KYC_AGENT: Agent = {
id: "agent-kyc",
name: "KYC Agent",
role: "Verifies identity documents and flags compliance gaps",
status: "draft",
version: "v2-draft",
model: "claude-sonnet-4.5",
toolMode: "restricted",
deniedTools: ["send.email", "invoke.webhook"],
evalsPassing: 14,
evalsTotal: 20,
scenarios: [
{
id: "sc-kyc-1",
name: "KYC question",
expectation: "Answer from the verified document set, never invent facts",
enabled: true,
},
{
id: "sc-kyc-2",
name: "Expired identity document",
expectation: "Mark verification as failed and request a fresh document",
enabled: true,
},
{
id: "sc-kyc-3",
name: "Compliance escalation",
expectation: "Escalate sanctions-list hits to the compliance officer",
enabled: true,
},
],
evalCases: [
{ id: "ec-kyc-1", name: "Valid passport", passing: true, latencyMs: 2200 },
{ id: "ec-kyc-2", name: "Expired licence", passing: true, latencyMs: 2050 },
{
id: "ec-kyc-3",
name: "Sanctions-list match",
passing: false,
latencyMs: 2600,
},
{
id: "ec-kyc-4",
name: "Mismatched name fields",
passing: false,
latencyMs: 2310,
},
],
versions: [
{
version: "v2-draft",
status: "draft",
createdAt: "2026-06-12T13:40:00Z",
author: "risk@acme.com",
note: "Sanctions-list escalation path (in review)",
},
{
version: "v1",
status: "published",
createdAt: "2026-03-22T10:15:00Z",
author: "risk@acme.com",
note: "Initial publish",
},
],
};
/**
* Enterprise-only agent — governance-heavy and exercised against a larger
* golden set than the base tenants get, so it only appears for that tier.
*/
const COMPLIANCE_SWEEP: Agent = {
id: "agent-compliance-sweep",
name: "Compliance Sweep",
role: "Audits processed documents against retention and PII policy",
status: "published",
version: "v4",
model: "claude-opus-4.1",
toolMode: "restricted",
deniedTools: ["send.email", "route.pipeline", "invoke.webhook"],
evalsPassing: 58,
evalsTotal: 60,
scenarios: [
{
id: "sc-cs-1",
name: "Over-retention detection",
expectation: "Flag documents held past their retention window",
enabled: true,
},
{
id: "sc-cs-2",
name: "Unredacted PII",
expectation: "Quarantine and write an audit entry, never auto-delete",
enabled: true,
},
],
evalCases: [
{
id: "ec-cs-1",
name: "Expired retention",
passing: true,
latencyMs: 3100,
},
{ id: "ec-cs-2", name: "SSN in free text", passing: true, latencyMs: 2900 },
{
id: "ec-cs-3",
name: "Redacted re-share",
passing: true,
latencyMs: 2700,
},
{
id: "ec-cs-4",
name: "Cross-region copy",
passing: false,
latencyMs: 3300,
},
],
versions: [
{
version: "v4",
status: "published",
createdAt: "2026-06-03T15:05:00Z",
author: "compliance@acme.com",
note: "Cross-region copy detection",
},
{
version: "v3",
status: "published",
createdAt: "2026-05-01T12:00:00Z",
author: "compliance@acme.com",
note: "Retention-window policy refresh",
},
{
version: "v2",
status: "published",
createdAt: "2026-03-14T09:00:00Z",
author: "compliance@acme.com",
note: "PII quarantine workflow",
},
{
version: "v1",
status: "published",
createdAt: "2026-01-20T08:00:00Z",
author: "you@acme.com",
note: "Initial publish",
},
],
};
/**
* On the free tier a single starter agent ships as a draft so the surface has
* something to render, but with an empty golden set and no version history —
* evals and version governance are paid capabilities.
*/
const STARTER_AGENT: Agent = {
id: "agent-starter",
name: "Starter Classifier",
role: "Sorts uploads into a handful of document types",
status: "draft",
version: "v1-draft",
model: "claude-haiku-4.5",
toolMode: "broad",
deniedTools: [],
evalsPassing: 0,
evalsTotal: 0,
scenarios: [
{
id: "sc-st-1",
name: "Unknown document type",
expectation: "Fall back to the inbox queue instead of guessing",
enabled: true,
},
],
evalCases: [],
versions: [
{
version: "v1-draft",
status: "draft",
createdAt: "2026-06-14T17:30:00Z",
author: "you@acme.com",
note: "Draft — not yet published",
},
],
};
const PRO_AGENTS: Agent[] = [CONTRACT_ROUTER, INVOICE_AGENT, KYC_AGENT];
/**
* Trim an agent to the free-tier shape: drop the eval golden set and collapse
* version history to the current version, mirroring the capability gating the
* KPI strip advertises.
*/
function toFreeShape(agent: Agent): Agent {
return {
...agent,
status: "draft",
evalsPassing: 0,
evalsTotal: 0,
evalCases: [],
versions: agent.versions.slice(0, 1),
};
}
/** Agents for a given tier. */
export function agentsFor(tier: Tier): Agent[] {
if (tier === "free") return [toFreeShape(STARTER_AGENT)];
if (tier === "enterprise") return [...PRO_AGENTS, COMPLIANCE_SWEEP];
return PRO_AGENTS;
}
function summaryFor(agents: Agent[]): AgentsSummary {
const published = agents.filter((a) => a.status === "published");
const totalScenarios = agents.reduce((n, a) => n + a.scenarios.length, 0);
// Pass-rate is meaningless without a golden set, so average only over agents
// that actually have eval cases — free tenants land at 0.
const withEvals = agents.filter((a) => a.evalsTotal > 0);
const avgPassRate =
withEvals.length === 0
? 0
: withEvals.reduce((sum, a) => sum + a.evalsPassing / a.evalsTotal, 0) /
withEvals.length;
const latestPublished = published.length > 0 ? published[0].version : "—";
return {
activeAgents: published.length,
totalAgents: agents.length,
avgPassRate,
totalScenarios,
latestPublished,
};
}
export function buildAgentsResponse(tier: Tier): AgentsResponse {
const agents = agentsFor(tier);
return { summary: summaryFor(agents), agents };
}
@@ -1,12 +0,0 @@
import { http, HttpResponse, delay } from "msw";
import type { Tier } from "@portal/contexts/TierContext";
import { buildAgentsResponse } from "@portal/mocks/agents";
export const agentsHandlers = [
http.get("/v1/agents", async ({ request }) => {
await delay(120);
const url = new URL(request.url);
const tier = (url.searchParams.get("tier") ?? "pro") as Tier;
return HttpResponse.json(buildAgentsResponse(tier));
}),
];
@@ -10,7 +10,6 @@ import { procurementSaasHandlers } from "@portal/mocks/handlers/procurementSaas"
import { docsHandlers } from "@portal/mocks/handlers/docs";
import { usersHandlers } from "@portal/mocks/handlers/users";
import { teamSaasHandlers } from "@portal/mocks/handlers/teamSaas";
import { agentsHandlers } from "@portal/mocks/handlers/agents";
import { policiesHandlers } from "@portal/mocks/handlers/policies";
import { classificationHandlers } from "@portal/mocks/handlers/classification";
import { documentsHandlers } from "@portal/mocks/handlers/documents";
@@ -30,7 +29,6 @@ export const handlers = [
...procurementSaasHandlers,
...usersHandlers,
...teamSaasHandlers,
...agentsHandlers,
...policiesHandlers,
...classificationHandlers,
...documentsHandlers,
@@ -1,458 +0,0 @@
.portal-agents {
display: flex;
flex-direction: column;
gap: 1.25rem;
padding: 1.5rem;
max-width: 84rem;
margin: 0 auto;
}
/* Header */
.portal-agents__head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.portal-agents__title {
margin: 0;
font-size: 1.375rem;
font-weight: 650;
color: var(--color-text-1);
}
.portal-agents__sub {
margin: 0.25rem 0 0;
font-size: 0.8125rem;
line-height: 1.5;
color: var(--color-text-3);
max-width: 46rem;
}
/* Two-column layout: selector rail + builder */
.portal-agents__layout {
display: grid;
grid-template-columns: 17rem 1fr;
gap: 1.25rem;
align-items: start;
}
@media (max-width: 56rem) {
.portal-agents__layout {
grid-template-columns: 1fr;
}
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Selector rail */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-agents__selector {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-agents__selector-item {
display: flex;
flex-direction: column;
gap: 0.5rem;
text-align: left;
padding: 0.75rem 0.875rem;
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-agents__selector-item:hover {
background: var(--color-bg-hover);
}
.portal-agents__selector-item.is-selected {
border-color: var(--color-blue);
background: var(--color-blue-light);
box-shadow: 0 0 0 1px var(--color-blue) inset;
}
.portal-agents__selector-main {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.portal-agents__selector-name {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-agents__selector-role {
font-size: 0.6875rem;
line-height: 1.4;
color: var(--color-text-4);
}
.portal-agents__selector-meta {
display: flex;
align-items: center;
gap: 0.5rem;
}
.portal-agents__selector-version,
.portal-agents__builder-version,
.portal-agents__builder-model,
.portal-agents__version-tag {
font-family: var(--font-mono);
font-size: 0.6875rem;
color: var(--color-text-3);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Builder panel */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-agents__builder {
background: var(--color-surface);
border: 1px solid var(--color-border);
border-radius: var(--radius-lg);
box-shadow: var(--shadow-sm);
padding: 1.25rem;
display: flex;
flex-direction: column;
gap: 1.125rem;
}
.portal-agents__builder-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
}
.portal-agents__builder-title {
margin: 0;
font-size: 1rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-agents__builder-sub {
font-size: 0.75rem;
color: var(--color-text-4);
}
.portal-agents__builder-meta {
display: flex;
align-items: center;
gap: 0.625rem;
flex-shrink: 0;
}
.portal-agents__builder-model {
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
padding: 0.125rem 0.375rem;
}
/* Shared panel body */
.portal-agents__panel {
display: flex;
flex-direction: column;
gap: 1.125rem;
}
.portal-agents__detail-section {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-agents__detail-heading {
font-size: 0.6875rem;
text-transform: uppercase;
letter-spacing: 0.04em;
color: var(--color-text-5);
font-weight: 600;
}
.portal-agents__chips {
display: flex;
flex-wrap: wrap;
gap: 0.375rem;
}
.portal-agents__hint {
margin: 0;
font-size: 0.75rem;
color: var(--color-text-4);
}
.portal-agents__muted {
font-size: 0.8125rem;
color: var(--color-text-4);
}
.portal-agents__mono {
font-family: var(--font-mono);
font-size: 0.75rem;
color: var(--color-text-2);
}
.portal-agents__stat-grid {
display: grid;
grid-template-columns: repeat(4, 1fr);
gap: 0.875rem;
}
.portal-agents__stat-grid--two {
grid-template-columns: repeat(2, minmax(0, 14rem));
}
/* Bars (eval pass-rate) */
.portal-agents__bar-row {
display: flex;
flex-direction: column;
gap: 0.4375rem;
}
.portal-agents__bar-head {
display: flex;
align-items: baseline;
justify-content: space-between;
font-size: 0.75rem;
color: var(--color-text-4);
}
.portal-agents__bar-head strong {
font-size: 0.8125rem;
color: var(--color-text-1);
font-family: var(--font-mono);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Scenarios */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-agents__scenarios {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-agents__scenario {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 0.875rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.portal-agents__scenario-text {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.portal-agents__scenario-head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.portal-agents__scenario-head strong {
font-size: 0.8125rem;
font-weight: 600;
color: var(--color-text-1);
}
.portal-agents__scenario-expect {
font-size: 0.75rem;
line-height: 1.45;
color: var(--color-text-3);
}
.portal-agents__scenario-add {
display: flex;
flex-direction: column;
gap: 0.625rem;
padding-top: 0.25rem;
border-top: 1px dashed var(--color-border-light);
}
.portal-agents__scenario-form {
display: grid;
grid-template-columns: 1fr 1.5fr auto;
align-items: end;
gap: 0.625rem;
}
@media (max-width: 48rem) {
.portal-agents__scenario-form {
grid-template-columns: 1fr;
}
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Tools */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-agents__tool-mode {
display: flex;
align-items: center;
justify-content: space-between;
gap: 1rem;
padding: 0.875rem 1rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Evals */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-agents__eval-head {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 1rem;
flex-wrap: wrap;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Versions */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-agents__versions {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.portal-agents__version {
display: flex;
align-items: center;
justify-content: space-between;
gap: 0.75rem;
padding: 0.75rem 0.875rem;
background: var(--color-bg-subtle);
border: 1px solid var(--color-border-light);
border-radius: var(--radius-md);
}
.portal-agents__version-main {
display: flex;
flex-direction: column;
gap: 0.25rem;
min-width: 0;
}
.portal-agents__version-head {
display: flex;
align-items: center;
gap: 0.5rem;
}
.portal-agents__version-tag {
font-size: 0.8125rem;
color: var(--color-text-1);
font-weight: 600;
}
.portal-agents__version-note {
font-size: 0.8125rem;
color: var(--color-text-2);
}
.portal-agents__version-meta {
font-size: 0.6875rem;
color: var(--color-text-5);
}
.portal-agents__version-actions {
flex-shrink: 0;
}
/* ──────────────────────────────────────────────────────────────────────── */
/* Bootstrap dialog */
/* ──────────────────────────────────────────────────────────────────────── */
.portal-agents__bootstrap {
display: flex;
flex-direction: column;
gap: 1rem;
}
.portal-agents__bootstrap-lead {
margin: 0;
font-size: 0.875rem;
line-height: 1.55;
color: var(--color-text-2);
}
.portal-agents__dropzone {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.5rem;
padding: 1.75rem 1rem;
text-align: center;
background: var(--color-bg-subtle);
border: 1px dashed var(--color-border);
border-radius: var(--radius-lg);
cursor: pointer;
transition:
border-color var(--motion-fast),
background var(--motion-fast);
}
.portal-agents__dropzone:hover {
border-color: var(--color-blue);
background: var(--color-blue-light);
}
.portal-agents__dropzone-input {
position: absolute;
width: 1px;
height: 1px;
padding: 0;
margin: -1px;
overflow: hidden;
clip: rect(0, 0, 0, 0);
white-space: nowrap;
border: 0;
}
.portal-agents__dropzone-icon {
font-size: 1.5rem;
color: var(--color-text-3);
}
.portal-agents__dropzone-text {
font-size: 0.8125rem;
color: var(--color-text-3);
}
.portal-agents__dialog-footer {
display: flex;
justify-content: space-between;
gap: 0.5rem;
width: 100%;
}
@@ -1,101 +0,0 @@
import { useState } from "react";
import { useTranslation } from "react-i18next";
import FileUploadRounded from "@mui/icons-material/FileUploadRounded";
import { Button, EmptyState, Skeleton } from "@app/ui";
import { useTier } from "@portal/contexts/TierContext";
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
import { fetchAgents, type AgentsResponse } from "@portal/api/agents";
import { AgentKpiStrip } from "@portal/components/agent-builder/AgentKpiStrip";
import { AgentSelector } from "@portal/components/agent-builder/AgentSelector";
import { AgentBuilderPanel } from "@portal/components/agent-builder/AgentBuilderPanel";
import { BootstrapDialog } from "@portal/components/agent-builder/BootstrapDialog";
import "@portal/views/AgentBuilder.css";
export function AgentBuilder() {
const { t } = useTranslation();
const { tier } = useTier();
const state = useAsync<AgentsResponse>(() => fetchAgents(tier), [tier]);
const { data, loading } = state;
const { isLoading, isEmpty } = useSectionFlags(state);
const [selectedId, setSelectedId] = useState<string | null>(null);
const [bootstrapOpen, setBootstrapOpen] = useState(false);
const agents = data?.agents ?? [];
// Default the selection to the first agent once data lands, but honour an
// explicit pick. Recomputed each render so a tier change re-points cleanly.
const selected = agents.find((a) => a.id === selectedId) ?? agents[0] ?? null;
// Enterprise unlocks restricted-tools governance and deep version history.
const governanceUnlocked = tier === "enterprise";
return (
<div className="portal-agents">
<header className="portal-agents__head">
<div>
<h1 className="portal-agents__title">
{t("portal.agentBuilder.title")}
</h1>
<p className="portal-agents__sub">
{t("portal.agentBuilder.subtitle")}
</p>
</div>
<Button
onClick={() => setBootstrapOpen(true)}
leftSection={<FileUploadRounded style={{ fontSize: "1.2rem" }} />}
>
{t("portal.agentBuilder.bootstrapFromDocument")}
</Button>
</header>
<AgentKpiStrip summary={data?.summary ?? null} loading={loading} />
{isLoading && (
<div className="portal-agents__layout">
<div className="portal-agents__selector" aria-hidden>
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} height="4rem" />
))}
</div>
<div className="portal-agents__builder" aria-hidden>
<Skeleton height="18rem" />
</div>
</div>
)}
{isEmpty && (
<EmptyState
title={t("portal.agentBuilder.empty.title")}
description={t("portal.agentBuilder.empty.description")}
actions={
<Button onClick={() => setBootstrapOpen(true)}>
{t("portal.agentBuilder.bootstrapFromDocument")}
</Button>
}
/>
)}
{!isLoading && !isEmpty && selected && (
<div className="portal-agents__layout">
<AgentSelector
agents={agents}
selectedId={selected.id}
onSelect={setSelectedId}
/>
<AgentBuilderPanel
// Remount on selection change so each panel re-seeds its local
// edit state from the freshly selected agent.
key={selected.id}
agent={selected}
governanceUnlocked={governanceUnlocked}
/>
</div>
)}
<BootstrapDialog
open={bootstrapOpen}
onClose={() => setBootstrapOpen(false)}
/>
</div>
);
}
@@ -31,11 +31,6 @@ vi.mock("@portal/api/integrations", () => ({
deleteIntegration: vi.fn(),
}));
// The Agent Builder header action is a flavor seam; stub it to keep the test focused.
vi.mock("@portal/components/sources/AgentBuilderAction", () => ({
AgentBuilderAction: () => null,
}));
const RESPONSE: SourcesResponse = {
kpis: [
{ value: 1, description: "" },
@@ -10,7 +10,6 @@ import {
type SourceView,
} from "@portal/api/sources";
import { VIEW_PATHS, toPortalPath } from "@portal/contexts/ViewContext";
import { AgentBuilderAction } from "@portal/components/sources/AgentBuilderAction";
import { KpiStrip } from "@portal/components/sources/KpiStrip";
import { SourcesTable } from "@portal/components/sources/SourcesTable";
import { ConnectionsTab } from "@portal/components/sources/ConnectionsTab";
@@ -55,7 +54,6 @@ export function Sources() {
</div>
{activeTab === "sources" && (
<div className="portal-sources__actions">
<AgentBuilderAction />
<Button
onClick={openCreate}
leftSection={<AddRoundedIcon style={{ fontSize: "1.125rem" }} />}