diff --git a/apps/web/src/agents-api.ts b/apps/web/src/agents-api.ts index 739031913..b14e24595 100644 --- a/apps/web/src/agents-api.ts +++ b/apps/web/src/agents-api.ts @@ -79,7 +79,7 @@ async function postJSON( path: string, schema: Validator, body: unknown, - method: "POST" | "PUT" = "POST", + method: "POST" | "PUT" | "DELETE" = "POST", ): Promise { let response: Response; try { @@ -245,6 +245,111 @@ export function getAgentCapabilities( ); } +/** `GET /agent-definitions/by-name/:slug` — one definition resolved by its + * immutable slug, server-side. A slug-addressed page reads this instead of + * scanning the paginated definitions listing, so an agent past that + * listing's ceiling still answers on its own URL. */ +export function getAgentDefinitionBySlug( + tenantId: string, + slug: string, +): Promise { + return getJSON( + `/api/tenants/${tenantId}/agent-definitions/by-name/${encodeURIComponent(slug)}`, + WorkflowDefinitionResponse, + ); +} + +const AgentDefinitionDetailResponse = type({ + name: "string", + systemPrompt: "string", + "model?": "string", + skills: "string[]", +}); +export type AgentDefinitionDetail = typeof AgentDefinitionDetailResponse.infer; + +/** Everything the agent detail page edits, read from the one route that + * owns a definition's authored state (`GET /agent-definitions/:id`): its + * display name, its system prompt, its pinned skills, and the model it + * resolves against. `name` here is the display name the definition's row + * carries, never its immutable slug. */ +export function getAgentDefinitionDetail( + tenantId: string, + definitionId: string, +): Promise { + return getJSON( + `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}`, + AgentDefinitionDetailResponse, + ); +} + +/** Replaces a definition's display name and system prompt in one write — + * the same route the per-workbench Assistant editor saves through, never + * a second write path of this page's own. */ +export function updateAgentInstructions( + tenantId: string, + definitionId: string, + input: { readonly name: string; readonly systemPrompt: string }, +): Promise<{ readonly name: string; readonly systemPrompt: string }> { + return postJSON( + `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}`, + type({ name: "string", systemPrompt: "string" }), + input, + "PUT", + ); +} + +const AgentCapabilitiesWriteResponse = type({ + skills: "string[]", + "model?": "string", +}); + +/** Sets the model a definition resolves against, through the guided + * capability-add route — which re-checks the name against the tenant's + * live catalog, so a model this bench cannot actually reach is refused + * rather than written. */ +export function setAgentModel( + tenantId: string, + definitionId: string, + canonicalName: string, +): Promise<{ readonly model?: string }> { + return postJSON( + `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}/capabilities`, + AgentCapabilitiesWriteResponse, + { kind: "model", canonicalName }, + ); +} + +/** Un-pins a definition's model, returning it to the bench default. Its own + * verb rather than `setAgentModel("")`: "no model" is not a name the + * capability route's inventory check could ever accept. */ +export function clearAgentModel( + tenantId: string, + definitionId: string, +): Promise<{ readonly model?: string }> { + return postJSON( + `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}/capabilities/model`, + AgentCapabilitiesWriteResponse, + {}, + "DELETE", + ); +} + +/** Archives (`stopped`) or restores (`deployed`) a definition. Nothing is + * deleted either way — an archived agent keeps its row, its asset, and its + * history, and simply stops appearing anywhere a person can launch it. */ +export function setAgentDefinitionStatus( + tenantId: string, + definitionId: string, + status: "deployed" | "stopped", +): Promise<{ readonly status: string }> { + return postJSON( + `/api/tenants/${tenantId}/agent-definitions/${encodeURIComponent(definitionId)}/status`, + type({ id: "string", status: "string" }), + { status }, + "PUT", + ); +} + const DefinitionSkillsMap = type({ skills: { "[string]": "string[]" } }); /** Every attached-skill list for the given definitions, keyed by definition diff --git a/apps/web/src/agents-directory.ts b/apps/web/src/agents-directory.ts index c5d8c75fd..02705a413 100644 --- a/apps/web/src/agents-directory.ts +++ b/apps/web/src/agents-directory.ts @@ -11,6 +11,7 @@ import { isOrphanedInstance as isOrphanedInstanceShared, purposeAgentDefinitions as purposeAgentDefinitionsShared, purposeAgentInstances as purposeAgentInstancesShared, + withDisplayName as withDisplayNameShared, withDisplayNames as withDisplayNamesShared, type WithDisplayName, } from "@corbits/agent-directory/client"; @@ -23,6 +24,14 @@ import type { AgentDefinition, AgentInstance } from "./agents-api"; * slug throughout; nothing here mutates it. */ export type AgentDefinitionWithDisplayName = WithDisplayName; +/** One definition's display name derived the same way the roster's are, so + * the same agent never reads under two different names across screens. */ +export function withAgentDisplayName( + definition: AgentDefinition, +): AgentDefinitionWithDisplayName { + return withDisplayNameShared(definition); +} + export function purposeAgentDefinitions( definitions: readonly AgentDefinition[], ): readonly AgentDefinitionWithDisplayName[] { diff --git a/apps/web/src/pages/agent-detail-page.tsx b/apps/web/src/pages/agent-detail-page.tsx new file mode 100644 index 000000000..2fee6a96d --- /dev/null +++ b/apps/web/src/pages/agent-detail-page.tsx @@ -0,0 +1,697 @@ +// The agent's own page (CL-6414), addressed by its immutable slug — +// `/agents/`. Everything a person can author about an agent lives +// here: its display name, the model it resolves against, the system prompt +// it follows on every turn, the skills it has pinned, and the runs it has +// produced. The roster's quick-peek panel stays where it is; this is the +// full page a person lands on when quick-peek isn't enough (DESIGN.md, +// "Detail Pages"). +// +// Every write goes through a mutation `@corbits/agent-directory` owns, via +// `../agents-api.ts` — this page invents no write path of its own: +// +// display name + system prompt PUT /agent-definitions/:id +// default model POST /agent-definitions/:id/capabilities +// clearing the model DELETE /agent-definitions/:id/capabilities/model +// pinned skills PUT /agent-definitions/:id/skills +// archive / restore PUT /agent-definitions/:id/status +// duplicate POST /agent-definitions +// +// One Save writes every dirty part and nothing else — an untouched field is +// never rewritten. Those parts are separate requests, so a Save can land +// partway: the page reports exactly which parts saved and which did not, +// and reloads either way, rather than claiming a clean failure over a +// half-applied change. Folding the three into one transactional route is +// the real fix and is deferred (see the PR). +// +// Two fields a person might expect are deliberately absent: the slug +// (immutable by design, so it renders as muted mono text rather than an +// input) and a separate description. A definition's row `description` IS +// its display name — that is where `deriveDisplayName` reads it from — and +// the purpose blurb inside the definition's own `workflow.json` has neither +// a read nor a write route today, so this page shows no description field +// rather than a control that silently edits the display name twice. Delete +// is likewise absent: archiving is the reversible lifecycle the platform +// actually backs, and tearing down a definition's asset and history has no +// route. + +import { + Badge, + Button, + Card, + ConfirmButton, + Input, + PageShell, + RichEmptyState, + Section, + Select, + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, + Textarea, +} from "@corbits/react-ui"; +import type { BadgeTone } from "@corbits/react-ui"; +import { Copy, Robot } from "@corbits/icons"; +import { useEffect, useState, type ReactNode } from "react"; +import { useQueryClient } from "@tanstack/react-query"; + +import { ApiQueryError, describeApiError, QueryView } from "@corbits/api-query"; +import { slugify } from "@corbits/slug"; + +import type { + AgentDefinition, + AgentDefinitionDetail, + AgentInstance, + CatalogModel, +} from "../agents-api"; +import { + clearAgentModel, + createAgentDefinition, + getAgentDefinitionBySlug, + getAgentDefinitionDetail, + setAgentDefinitionStatus, + setAgentModel, + updateAgentInstructions, + updateAgentSkills, + useAgentDirectory, +} from "../agents-api"; +import type { AgentDefinitionWithDisplayName } from "../agents-directory"; +import { withAgentDisplayName } from "../agents-directory"; +import { useBench } from "../bench-context"; +import { runDetailPath } from "../insights-deeplinks"; +import { Link } from "../navigation"; +import { AGENTS_PATH_PREFIX } from "../path-ids"; +import { tenantKeys } from "../query-client"; +import { StageTopBar } from "../shell/stage-top-bar"; +import { AgentSkillsPicker } from "./agent-skills-picker"; + +const STATUS_TONE: Record<"deployed" | "stopped", BadgeTone> = { + deployed: "success", + stopped: "neutral", +}; + +/** A run's state in the words a person uses for it, never the wire enum + * (DESIGN.md, "Copy"). */ +const RUN_STATUS_COPY: Record< + AgentInstance["status"], + { readonly label: string; readonly tone: BadgeTone } +> = { + deployed: { label: "Ready", tone: "neutral" }, + running: { label: "Running now", tone: "success" }, + updating: { label: "Updating", tone: "warning" }, + error: { label: "Failed", tone: "danger" }, + stopped: { label: "Stopped", tone: "neutral" }, +}; + +/** How many of a definition's runs the page lists. Recent history, not a + * runs browser — Insights owns that surface, and every row here links + * into it. */ +const RECENT_RUN_LIMIT = 10; + +function sameSkillSet(a: readonly string[], b: readonly string[]): boolean { + return a.length === b.length && a.every((name) => b.includes(name)); +} + +/** The runs to show for a definition: its own, newest first, capped. */ +export function recentRunsForDefinition( + runs: readonly AgentInstance[], + definitionId: string, +): readonly AgentInstance[] { + return runs + .filter((run) => run.definitionId === definitionId) + .toSorted((left, right) => right.createdAt.localeCompare(left.createdAt)) + .slice(0, RECENT_RUN_LIMIT); +} + +/** The handle a duplicate is created under: the original's slug plus a + * `-copy` suffix, kebab-safe. A second duplicate collides on the handle + * and surfaces that collision in words rather than guessing `-copy-2`. */ +export function duplicateHandle(slug: string): string { + return slugify(`${slug}-copy`); +} + +/** The parts a Save writes, each its own request. */ +export type SavePart = "instructions" | "model" | "skills"; + +const SAVE_PART_COPY: Record = { + instructions: "name and system prompt", + model: "default model", + skills: "skills", +}; + +/** + * What a Save actually did. `failed` names the first part that did not + * land; `saved` names the parts that already committed before it, which is + * the whole reason this is reported rather than a bare error — those writes + * are real and the person needs to know they happened. + */ +export type SaveReport = { + readonly saved: readonly SavePart[]; + readonly failed: { readonly part: SavePart; readonly message: string } | null; +}; + +/** The sentence a report reads as. */ +export function describeSaveReport(report: SaveReport): string { + const saved = report.saved.map((part) => SAVE_PART_COPY[part]).join(", "); + if (report.failed === null) { + return `Saved ${saved}.`; + } + const failed = `Couldn't save this agent's ${SAVE_PART_COPY[report.failed.part]}: ${report.failed.message}`; + return report.saved.length === 0 + ? failed + : `Saved ${saved} — then ${failed.slice(0, 1).toLowerCase()}${failed.slice(1)}`; +} + +type WriteState = + | { readonly kind: "idle" } + | { readonly kind: "busy" } + | { readonly kind: "error"; readonly message: string }; + +export function AgentDetailPage({ + tenantId, + definition, + detail, + models, + runs, + saveReport, + onSaved, + onDuplicated, + onStatusChanged, +}: { + readonly tenantId: string; + readonly definition: AgentDefinitionWithDisplayName; + readonly detail: AgentDefinitionDetail; + readonly models: readonly CatalogModel[]; + readonly runs: readonly AgentInstance[]; + /** The outcome of the Save that produced the state now on screen, held by + * the route so it survives the reload a Save triggers. */ + readonly saveReport: SaveReport | null; + readonly onSaved: (report: SaveReport) => void; + readonly onDuplicated: (slug: string) => Promise; + readonly onStatusChanged: () => void; +}) { + const [displayName, setDisplayName] = useState(definition.displayName); + const [systemPrompt, setSystemPrompt] = useState(detail.systemPrompt); + const [model, setModel] = useState(detail.model ?? ""); + const [skills, setSkills] = useState(detail.skills); + const [save, setSave] = useState({ kind: "idle" }); + const [lifecycle, setLifecycle] = useState({ kind: "idle" }); + + const archived = definition.status === "stopped"; + const trimmedName = displayName.trim(); + const trimmedPrompt = systemPrompt.trim(); + const instructionsDirty = + trimmedName !== definition.displayName || + trimmedPrompt !== detail.systemPrompt; + // Compared against the loaded value alone, so picking "Bench default" on + // an agent with a pinned model is a real edit — clearing a model is a + // change like any other, not the absence of one. + const modelDirty = model !== (detail.model ?? ""); + const skillsDirty = !sameSkillSet(skills, detail.skills); + const dirty = instructionsDirty || modelDirty || skillsDirty; + const saveable = + dirty && trimmedName !== "" && trimmedPrompt !== "" && save.kind !== "busy"; + + async function onSave() { + setSave({ kind: "busy" }); + const parts: readonly { part: SavePart; write: () => Promise }[] = + [ + ...(instructionsDirty + ? [ + { + part: "instructions" as const, + write: () => + updateAgentInstructions(tenantId, definition.id, { + name: trimmedName, + systemPrompt: trimmedPrompt, + }), + }, + ] + : []), + ...(modelDirty + ? [ + { + part: "model" as const, + write: () => + model === "" + ? clearAgentModel(tenantId, definition.id) + : setAgentModel(tenantId, definition.id, model), + }, + ] + : []), + ...(skillsDirty + ? [ + { + part: "skills" as const, + write: () => + updateAgentSkills(tenantId, definition.id, [...skills]), + }, + ] + : []), + ]; + + const saved: SavePart[] = []; + for (const { part, write } of parts) { + try { + await write(); + saved.push(part); + } catch (cause: unknown) { + setSave({ kind: "idle" }); + // Reported, not thrown away: the parts already in `saved` are + // committed on the server, so the page reloads to show them rather + // than leaving a screen that disagrees with what was written. + onSaved({ + saved, + failed: { + part, + message: describeApiError( + cause, + `saving this agent's ${SAVE_PART_COPY[part]}`, + ), + }, + }); + return; + } + } + setSave({ kind: "idle" }); + onSaved({ saved, failed: null }); + } + + async function onDuplicate() { + setLifecycle({ kind: "busy" }); + const handle = duplicateHandle(definition.name); + try { + await createAgentDefinition(tenantId, { + name: `${definition.displayName} copy`, + handle, + systemPrompt: detail.systemPrompt, + ...(detail.model !== undefined ? { model: detail.model } : {}), + skills: [...detail.skills], + }); + await onDuplicated(handle); + setLifecycle({ kind: "idle" }); + } catch (cause: unknown) { + // A handle collision is the one failure retrying can never clear, so + // it says what already exists instead of "try again". + const conflict = cause instanceof ApiQueryError && cause.status === 409; + setLifecycle({ + kind: "error", + message: conflict + ? `"${handle}" already exists — open that copy, or rename it, before duplicating this agent again.` + : describeApiError(cause, "duplicating this agent"), + }); + } + } + + async function onToggleArchived() { + setLifecycle({ kind: "busy" }); + try { + await setAgentDefinitionStatus( + tenantId, + definition.id, + archived ? "deployed" : "stopped", + ); + setLifecycle({ kind: "idle" }); + onStatusChanged(); + } catch (cause: unknown) { + setLifecycle({ + kind: "error", + message: describeApiError( + cause, + archived ? "restoring this agent" : "archiving this agent", + ), + }); + } + } + + const recent = recentRunsForDefinition(runs, definition.id); + + return ( +
+ + + void onToggleArchived()} + aria-label={ + archived ? "Restore this agent" : "Archive this agent" + } + > + {archived ? "Restore" : "Archive"} + + + + } + /> +
+ +
+ {saveReport !== null ? ( +

+ {describeSaveReport(saveReport)} +

+ ) : null} + {lifecycle.kind === "error" ? ( +

+ {lifecycle.message} +

+ ) : null} + {dirty ? ( +

+ Unsaved edits — Duplicate copies the saved version, so it waits + until you save. +

+ ) : null} + + +
+ + setDisplayName(event.target.value)} + /> +

+ {definition.name} +

+

+ The handle above is this agent's address and its URL. It + never changes. +

+
+
+ + {archived ? "Archived" : "Active"} + + + {archived + ? "Archived — nobody can start a new chat with it until it is restored. Chats already running keep going." + : "Active — anyone in this bench can start a chat with it."} + +
+
+ + {models.length === 0 ? ( +

+ No models in this bench's catalog yet — connect a + provider in Settings and this agent can pick one. +

+ ) : ( + + )} +
+
+ +
+