diff --git a/apps/web/app/api/v1/agents/[id]/profile/route.ts b/apps/web/app/api/v1/agents/[id]/profile/route.ts new file mode 100644 index 0000000..0ca12a6 --- /dev/null +++ b/apps/web/app/api/v1/agents/[id]/profile/route.ts @@ -0,0 +1,24 @@ +import { requireCapability } from "@muster/authz"; +import { apiSubject, problemResponse, requestTraceId } from "@/lib/api-context"; +import { agentProfile } from "@/lib/agent-profile-domain"; + +export const dynamic = "force-dynamic"; +export const runtime = "nodejs"; + +export async function GET( + request: Request, + { params }: { params: Promise<{ id: string }> }, +) { + const traceId = requestTraceId(request); + try { + const subject = await apiSubject(request); + requireCapability(subject, "agents.read"); + const { id } = await params; + return Response.json({ + data: await agentProfile(subject.organisationId, id), + traceId, + }); + } catch (error) { + return problemResponse(error, traceId); + } +} diff --git a/apps/web/components/agent-profile-panels.tsx b/apps/web/components/agent-profile-panels.tsx new file mode 100644 index 0000000..4f0c631 --- /dev/null +++ b/apps/web/components/agent-profile-panels.tsx @@ -0,0 +1,351 @@ +"use client"; + +import { useQuery } from "@tanstack/react-query"; +import { AlertTriangle } from "lucide-react"; +import { ErrorState } from "@/components/os/error-state"; +import { SkeletonRows } from "@/components/os/skeleton"; +import { Badge } from "@/components/ui/badge"; +import { apiGet } from "@/lib/api/client"; +import { relativeTime } from "@/lib/utils"; +import type { + AgentProfile, + AgentRoomProfile, + AgentToolProfile, +} from "@/lib/agent-profile-domain"; + +export type AgentProfileTab = "tools" | "rooms" | "permissions"; + +function useAgentProfile(agentId: string) { + return useQuery({ + queryKey: ["agents", agentId, "profile"], + queryFn: async () => { + const res = await apiGet( + `/api/v1/agents/${encodeURIComponent(agentId)}/profile`, + ); + return res.data; + }, + staleTime: 30_000, + }); +} + +function Section({ + title, + hint, + children, +}: { + title: string; + hint: string; + children: React.ReactNode; +}) { + return ( +
+
+

{title}

+

{hint}

+
+ {children} +
+ ); +} + +function ToolRow({ tool }: { tool: AgentToolProfile }) { + return ( +
  • + + {tool.name} + + {tool.registered + ? `Requires ${tool.capability}` + : "Not implemented by the runtime registry"} + + + {tool.mutation === true ? ( + + writes + + ) : tool.mutation === false ? ( + read only + ) : null} + {tool.approvalAction ? ( + + {tool.approvalAction} + + ) : null} + {!tool.registered ? ( + + unregistered + + ) : null} + + {tool.callCount} calls + + + {tool.lastUsedAt ? relativeTime(tool.lastUsedAt) : "never used"} + +
  • + ); +} + +function RoomRow({ room }: { room: AgentRoomProfile }) { + return ( +
  • + + + {room.displayName} + + + {room.slug} + + + {room.roomType} + {room.allowed ? ( + allowed + ) : ( + + member, not allow-listed + + )} + {room.member ? null : ( + no membership + )} +
  • + ); +} + +function CapabilityList({ + items, + empty, + tone, +}: { + items: string[]; + empty: string; + tone?: "error"; +}) { + if (items.length === 0) + return

    {empty}

    ; + return ( +

    + {items.map((item) => ( + + {item} + + ))} +

    + ); +} + +/** + * Read-only governance detail for one agent. Grants and allow-lists are + * server-controlled; this shows what is configured and where configuration + * and reality disagree. + */ +export function AgentProfilePanel({ + agentId, + tab, +}: { + agentId: string; + tab: AgentProfileTab; +}) { + const profile = useAgentProfile(agentId); + + if (profile.isError) + return ( + void profile.refetch()} /> + ); + if (!profile.data) return ; + const data = profile.data; + + if (tab === "tools") { + return ( +
    + {data.tools.length === 0 ? ( +

    + No tools are declared for this agent and none have been called. +

    + ) : ( +
      + {data.tools.map((tool) => ( + + ))} +
    + )} +
    + ); + } + + if (tab === "rooms") { + return ( +
    +
    + {data.rooms.length === 0 ? ( +

    + No rooms are allow-listed and no membership exists. +

    + ) : ( +
      + {data.rooms.map((room) => ( + + ))} +
    + )} +
    + +
    + {data.slackExposures.length === 0 ? ( +

    + This agent is not exposed in any Slack installation. +

    + ) : ( +
      + {data.slackExposures.map((exposure) => ( +
    • + + {exposure.teamName ?? exposure.installationId} + + {exposure.isDefault ? ( + + default agent + + ) : null} + + {exposure.enabled ? "enabled" : "disabled"} + +
    • + ))} +
    + )} +
    +
    + ); + } + + const { permissions } = data; + return ( +
    + {permissions.missing.length > 0 ? ( +
    + +
    +

    + Declared requirements this agent does not hold +

    +

    + A run needing one of these fails with a capability error. Grant + them or narrow the definition. +

    +
    + +
    +
    +
    + ) : null} + +
    +
    + +
    +
    + +
    +
    + +
    +
    + + {permissions.surplus.length > 0 ? ( +
    +
    + +
    +
    + ) : null} + + {permissions.unknown.length > 0 ? ( +
    +
    + +
    +
    + ) : null} + +
    +
    +
    +
    Runtime
    +
    + {permissions.budgets.maximumRuntimeSeconds}s +
    +
    +
    +
    Tokens
    +
    + {permissions.budgets.maximumTokenBudget.toLocaleString()} +
    +
    +
    +
    Cost
    +
    + {(permissions.budgets.maximumCostCents / 100).toFixed(2)} +
    +
    +
    +
    +
    + ); +} diff --git a/apps/web/components/agent-surfaces.test.ts b/apps/web/components/agent-surfaces.test.ts index 224dc9c..d233c17 100644 --- a/apps/web/components/agent-surfaces.test.ts +++ b/apps/web/components/agent-surfaces.test.ts @@ -8,21 +8,58 @@ async function source(name: string) { describe("agent detail tabs", () => { it("only lists tabs that render their own content", async () => { const view = await source("./agents-view.tsx"); - expect(view).toContain('const agentTabs = ["Overview", "Learning"];'); + expect(view).toContain( + 'const agentTabs = ["Overview", "Tools", "Rooms", "Permissions", "Learning"];', + ); // Every listed tab must have a branch, or it silently shows Overview again. - for (const dead of [ - '"Instructions"', - '"Tools"', - '"Permissions"', - '"Rooms"', - '"Evaluations"', - '"Versions"', - ]) { - expect(view).not.toContain(` ${dead},`); + for (const tab of ["tools", "rooms", "permissions", "learning"]) { + expect(view).toContain(`"${tab}"`); + } + // Still unbuilt: absent rather than falling through to Overview. + for (const dead of ['"Instructions"', '"Evaluations"', '"Versions"']) { + expect(view).not.toContain(dead); } }); }); +describe("agent profile", () => { + it("reports the gap between declared requirements and real grants", async () => { + const domain = await source("../lib/agent-profile-domain.ts"); + // The governance-critical field: required but not held means every run + // touching it fails, and nothing else in the product surfaces that. + expect(domain).toContain("missing:"); + expect(domain).toContain("surplus:"); + expect(domain).toContain("unknown:"); + const panel = await source("./agent-profile-panels.tsx"); + expect(panel).toContain("Declared requirements this agent does not hold"); + }); + + it("scopes every read to the caller's organisation", async () => { + const domain = await source("../lib/agent-profile-domain.ts"); + const froms = + (domain.match(/\.from\(schema\./g)?.length ?? 0) - + // innerJoin targets are constrained by their join predicate. + (domain.match(/\.innerJoin\(/g)?.length ?? 0); + const scoped = + domain.match(/organisationId, organisationId\)/g)?.length ?? 0; + expect(froms).toBeGreaterThan(0); + expect(scoped).toBeGreaterThanOrEqual(froms); + }); + + it("surfaces tools called outside the declared envelope", async () => { + const domain = await source("../lib/agent-profile-domain.ts"); + expect(domain).toContain("...allowedTools, ...usage.map"); + const panel = await source("./agent-profile-panels.tsx"); + expect(panel).toContain("unregistered"); + }); + + it("is read-only: no grant or revoke path in the UI", async () => { + const panel = await source("./agent-profile-panels.tsx"); + expect(panel).not.toContain("apiPost"); + expect(panel).not.toContain("useMutation"); + }); +}); + describe("agent run detail", () => { it("shows why a run failed instead of only its status", async () => { const view = await source("./agent-run-view.tsx"); diff --git a/apps/web/components/agents-view.tsx b/apps/web/components/agents-view.tsx index 2103af6..56a95b6 100644 --- a/apps/web/components/agents-view.tsx +++ b/apps/web/components/agents-view.tsx @@ -15,6 +15,7 @@ import { OpsShell } from "@/components/ops-shell"; import { PageHeader } from "@/components/page-header"; import { Avatar } from "@/components/ui/avatar"; import { Badge } from "@/components/ui/badge"; +import { AgentProfilePanel } from "@/components/agent-profile-panels"; import { Button, buttonVariants } from "@/components/ui/button"; type EvidenceState = "reported" | "unavailable" | "unknown"; @@ -228,14 +229,12 @@ export function AgentsView() { } /** - * Only tabs that render distinct content. Instructions, Tools, Permissions, - * Rooms, Runs, Evaluations, Versions, and Audit all fell through to the - * Overview panel, so eight links looked navigable and silently showed the - * same page. Overview already carries the permission, runtime, and tool - * evidence the readiness payload actually provides; the rest need APIs that - * do not exist yet. Add a tab back when it has something of its own to show. + * Only tabs that render distinct content. Instructions, Runs, Evaluations, + * Versions, and Audit remain unbuilt and are deliberately absent rather than + * silently falling through to Overview. Add a tab back when it has something + * of its own to show. */ -const agentTabs = ["Overview", "Learning"]; +const agentTabs = ["Overview", "Tools", "Rooms", "Permissions", "Learning"]; export function AgentDetailView({ agentId, @@ -338,6 +337,8 @@ export function AgentDetailView({
    {tab === "learning" ? ( + ) : tab === "tools" || tab === "rooms" || tab === "permissions" ? ( + ) : ( )} diff --git a/apps/web/lib/agent-profile-domain.ts b/apps/web/lib/agent-profile-domain.ts new file mode 100644 index 0000000..e7374cc --- /dev/null +++ b/apps/web/lib/agent-profile-domain.ts @@ -0,0 +1,284 @@ +import { and, count, desc, eq, inArray, max } from "drizzle-orm"; +import { agentToolRegistry } from "@muster/agents"; +import { capabilities as declaredCapabilities } from "@muster/authz"; +import { database, schema } from "@muster/database"; +import { ApiProblem } from "./api-context.ts"; + +export type AgentToolProfile = { + name: string; + /** Null when the definition allows a tool the runtime registry does not implement. */ + capability: string | null; + mutation: boolean | null; + approvalAction: string | null; + registered: boolean; + callCount: number; + lastUsedAt: string | null; +}; + +export type AgentRoomProfile = { + id: string; + slug: string; + displayName: string; + roomType: string; + /** Listed in the definition's allowedRooms. */ + allowed: boolean; + /** Actually holds a membership row. */ + member: boolean; +}; + +export type AgentPermissionProfile = { + required: string[]; + granted: string[]; + /** + * Required but not granted. An agent in this state fails at run time with a + * capability error, and nothing else in the product surfaces it. + */ + missing: string[]; + /** Granted beyond what the definition declares it needs. */ + surplus: string[]; + /** Declared requirements that are not real capabilities at all. */ + unknown: string[]; + approvalRequirements: Record; + budgets: { + maximumRuntimeSeconds: number; + maximumTokenBudget: number; + maximumCostCents: number; + }; +}; + +export type AgentProfile = { + id: string; + name: string; + description: string; + status: string; + killSwitch: boolean; + runtime: string; + model: string; + systemPromptVersion: string; + tools: AgentToolProfile[]; + rooms: AgentRoomProfile[]; + slackExposures: Array<{ + installationId: string; + teamName: string | null; + enabled: boolean; + isDefault: boolean; + }>; + permissions: AgentPermissionProfile; +}; + +function stringList(value: unknown): string[] { + return Array.isArray(value) + ? value.filter((item): item is string => typeof item === "string") + : []; +} + +/** + * Read-only governance profile for one agent: what it may use, where it may + * work, and whether its declared requirements match the capabilities it + * actually holds. Every field is derived from stored state — nothing here + * grants, revokes, or infers. + */ +export async function agentProfile( + organisationId: string, + agentId: string, +): Promise { + const db = database(); + + const [definition] = await db + .select({ + id: schema.agentDefinitions.id, + name: schema.agentDefinitions.name, + description: schema.agentDefinitions.description, + status: schema.agentDefinitions.status, + killSwitch: schema.agentDefinitions.killSwitch, + runtime: schema.agentDefinitions.runtime, + model: schema.agentDefinitions.model, + systemPromptVersion: schema.agentDefinitions.systemPromptVersion, + allowedTools: schema.agentDefinitions.allowedTools, + allowedRooms: schema.agentDefinitions.allowedRooms, + capabilityRequirements: schema.agentDefinitions.capabilityRequirements, + approvalRequirements: schema.agentDefinitions.approvalRequirements, + maximumRuntimeSeconds: schema.agentDefinitions.maximumRuntimeSeconds, + maximumTokenBudget: schema.agentDefinitions.maximumTokenBudget, + maximumCostCents: schema.agentDefinitions.maximumCostCents, + }) + .from(schema.agentDefinitions) + .where( + and( + eq(schema.agentDefinitions.id, agentId), + eq(schema.agentDefinitions.organisationId, organisationId), + ), + ) + .limit(1); + if (!definition) throw new ApiProblem(404, "Not found", "Agent not found."); + + const allowedTools = stringList(definition.allowedTools); + const allowedRooms = stringList(definition.allowedRooms); + const required = stringList(definition.capabilityRequirements); + + const [actor] = await db + .select({ capabilityAssignments: schema.actors.capabilityAssignments }) + .from(schema.actors) + .where( + and( + eq(schema.actors.id, agentId), + eq(schema.actors.organisationId, organisationId), + ), + ) + .limit(1); + const granted = stringList(actor?.capabilityAssignments); + + // Tool usage is per run, so aggregate through the agent's own runs rather + // than trusting a tool name to be unique across the organisation. + const usage = await db + .select({ + toolName: schema.agentToolCalls.toolName, + callCount: count(), + lastUsedAt: max(schema.agentToolCalls.startedAt), + }) + .from(schema.agentToolCalls) + .innerJoin( + schema.agentRuns, + and( + eq(schema.agentRuns.id, schema.agentToolCalls.runId), + eq(schema.agentRuns.organisationId, schema.agentToolCalls.organisationId), + ), + ) + .where( + and( + eq(schema.agentToolCalls.organisationId, organisationId), + eq(schema.agentRuns.agentId, agentId), + ), + ) + .groupBy(schema.agentToolCalls.toolName); + const usageByTool = new Map(usage.map((row) => [row.toolName, row])); + + // Surface tools the agent has actually called even when the definition no + // longer lists them — a call outside the declared envelope is exactly what + // an operator needs to see. + const toolNames = [ + ...new Set([...allowedTools, ...usage.map((row) => row.toolName)]), + ].sort(); + const tools: AgentToolProfile[] = toolNames.map((name) => { + const registered = agentToolRegistry.get(name); + const used = usageByTool.get(name); + return { + name, + capability: registered?.capability ?? null, + mutation: registered?.mutation ?? null, + approvalAction: registered?.approvalAction ?? null, + registered: Boolean(registered), + callCount: Number(used?.callCount ?? 0), + lastUsedAt: used?.lastUsedAt?.toISOString() ?? null, + }; + }); + + const memberships = await db + .select({ roomId: schema.roomMemberships.roomId }) + .from(schema.roomMemberships) + .where( + and( + eq(schema.roomMemberships.organisationId, organisationId), + eq(schema.roomMemberships.actorId, agentId), + ), + ); + const memberRoomIds = new Set(memberships.map((row) => row.roomId)); + const roomIds = [...new Set([...allowedRooms, ...memberRoomIds])]; + const roomRows = roomIds.length + ? await db + .select({ + id: schema.rooms.id, + slug: schema.rooms.slug, + displayName: schema.rooms.displayName, + roomType: schema.rooms.roomType, + }) + .from(schema.rooms) + .where( + and( + eq(schema.rooms.organisationId, organisationId), + inArray(schema.rooms.id, roomIds), + ), + ) + : []; + const allowedRoomIds = new Set(allowedRooms); + const rooms: AgentRoomProfile[] = roomRows + .map((room) => ({ + id: room.id, + slug: room.slug, + displayName: room.displayName, + roomType: room.roomType, + allowed: allowedRoomIds.has(room.id), + member: memberRoomIds.has(room.id), + })) + .sort((left, right) => left.displayName.localeCompare(right.displayName)); + + const exposures = await db + .select({ + installationId: schema.slackAgentExposures.installationId, + enabled: schema.slackAgentExposures.enabled, + isDefault: schema.slackAgentExposures.isDefault, + teamName: schema.slackInstallations.teamName, + }) + .from(schema.slackAgentExposures) + .leftJoin( + schema.slackInstallations, + and( + eq( + schema.slackInstallations.id, + schema.slackAgentExposures.installationId, + ), + eq( + schema.slackInstallations.organisationId, + schema.slackAgentExposures.organisationId, + ), + ), + ) + .where( + and( + eq(schema.slackAgentExposures.organisationId, organisationId), + eq(schema.slackAgentExposures.agentId, agentId), + ), + ) + .orderBy(desc(schema.slackAgentExposures.isDefault)); + + const grantedSet = new Set(granted); + const requiredSet = new Set(required); + const declared = new Set(declaredCapabilities); + + return { + id: definition.id, + name: definition.name, + description: definition.description, + status: definition.status, + killSwitch: definition.killSwitch, + runtime: definition.runtime, + model: definition.model, + systemPromptVersion: definition.systemPromptVersion, + tools, + rooms, + slackExposures: exposures.map((row) => ({ + installationId: row.installationId, + teamName: row.teamName, + enabled: row.enabled, + isDefault: row.isDefault, + })), + permissions: { + required: [...requiredSet].sort(), + granted: [...grantedSet].sort(), + missing: [...requiredSet].filter((item) => !grantedSet.has(item)).sort(), + surplus: [...grantedSet].filter((item) => !requiredSet.has(item)).sort(), + unknown: [...requiredSet].filter((item) => !declared.has(item)).sort(), + approvalRequirements: + definition.approvalRequirements && + typeof definition.approvalRequirements === "object" && + !Array.isArray(definition.approvalRequirements) + ? (definition.approvalRequirements as Record) + : {}, + budgets: { + maximumRuntimeSeconds: definition.maximumRuntimeSeconds, + maximumTokenBudget: definition.maximumTokenBudget, + maximumCostCents: definition.maximumCostCents, + }, + }, + }; +}