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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 0 additions & 15 deletions apps/web/src/agents-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -289,18 +289,3 @@ export function useDeployAgentMutation(tenantId: string) {
},
});
}

// The guided capability-add surface: only what this tenant actually has.
const CapabilityInventoryWire = type({
toolPackages: type({ name: "string" }).array(),
skills: type({ name: "string" }).array(),
models: type({ canonicalName: "string" }).array(),
});
export type CapabilityInventory = typeof CapabilityInventoryWire.infer;

export function listCapabilityInventory(tenantId: string): Promise<CapabilityInventory> {
return getJSON(
`/api/tenants/${tenantId}/agent-definitions/capabilities/inventory`,
CapabilityInventoryWire,
);
}
125 changes: 41 additions & 84 deletions apps/web/src/pages/tools-page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,8 @@
// Tools: a standalone rail destination listing what this tenant can call —
// Interchange tool packages, read from the same capability inventory the
// Agents detail view's "Add a capability" picker already uses
// (`listCapabilityInventory`, `packages/agent-directory`'s package-registry
// read). There is no connect flow here: pinning a tool package to an agent
// happens on that agent's own detail page. No stock route lists a tenant's
// MCP servers yet, so this page has nothing to show for those until one
// exists.
// Tools: a standalone rail destination listing the tool packages this
// tenant has published into its own registry — the same
// `corbits-tools` package-registry asset `registry-publish.ts` writes to.
// No stock route lists a tenant's MCP servers yet, so this page has
// nothing to show for those until one exists.

import {
PageShell,
Expand All @@ -17,46 +14,19 @@ import {
TableHeader,
TableRow,
} from "@corbits/react-ui";
import { QueryView } from "@/lib/api-query";
import { Plugs } from "@/lib/icons";
import { WorkbenchLoadingState } from "@/chat";
import { useCallback, useEffect, useState } from "react";

import { listCapabilityInventory, type CapabilityInventory } from "../agents-api";
import { useToolPackages } from "../tools/registry-read";
import { useBench } from "../bench-context";
import { StageTopBar } from "../shell/stage-top-bar";

type ToolsState =
| { readonly status: "loading" }
| { readonly status: "ready"; readonly inventory: CapabilityInventory }
| { readonly status: "error"; readonly message: string };

function messageOf(cause: unknown): string {
return cause instanceof Error ? cause.message : String(cause);
}

/**
* The tenant's installed tool packages, over the same capability inventory
* the Agents detail view reads. `tenantId` is the tenant every read is
* scoped to.
* The tenant's published tool packages, read off the stock registry asset.
* `tenantId` is the tenant every read is scoped to.
*/
export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) {
const [state, setState] = useState<ToolsState>({ status: "loading" });

const reload = useCallback(async () => {
if (tenantId === null) return;
setState({ status: "loading" });
try {
const inventory = await listCapabilityInventory(tenantId);
setState({ status: "ready", inventory });
} catch (cause) {
setState({ status: "error", message: messageOf(cause) });
}
}, [tenantId]);

useEffect(() => {
void reload();
}, [reload]);

const query = useToolPackages(tenantId);
const crumbs = [{ label: "Tools" }];

function stage(body: React.ReactNode) {
Expand All @@ -78,56 +48,43 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) {
);
}

if (state.status === "loading") {
return stage(<WorkbenchLoadingState title="Loading tools…" />);
}

if (state.status === "error") {
return stage(
<RichEmptyState
icon={<Plugs />}
title="Couldn't load your tools"
description="Something went wrong on our side. Try again in a moment."
actions={[{ label: "Retry", onClick: () => void reload() }]}
/>,
);
}

const { toolPackages } = state.inventory;

if (toolPackages.length === 0) {
return stage(
<RichEmptyState
icon={<Plugs />}
title="No tools yet"
description="A tool package gives every agent in this workbench a new capability. Install one to see it here."
/>,
);
}

return stage(
<div className="px-4 pb-5 sm:px-7">
<Table aria-label="Tools">
<TableHeader>
<TableRow>
<TableHead>Tool package</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{toolPackages.map((tool) => (
<TableRow key={tool.name}>
<TableCell className="font-medium">{tool.name}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>,
<QueryView query={query} label="your tools" skeleton="rows">
{(toolPackages) =>
toolPackages.length === 0 ? (
<RichEmptyState
icon={<Plugs />}
title="No tools yet"
description="A tool package gives every agent in this workbench a new capability. Publish one to see it here."
/>
) : (
<div className="px-4 pb-5 sm:px-7">
<Table aria-label="Tools">
<TableHeader>
<TableRow>
<TableHead>Tool package</TableHead>
<TableHead>Version</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{toolPackages.map((tool) => (
<TableRow key={tool.filename}>
<TableCell className="font-medium">{tool.name}</TableCell>
<TableCell className="text-muted-foreground">{tool.version}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
</div>
)
}
</QueryView>,
);
}

/**
* Tools roster mount at `/tools`: a thin adapter that resolves which
* workbench's inventory is listed. The stage chrome lives on `ToolsPage`.
* workbench's registry is listed. The stage chrome lives on `ToolsPage`.
*/
export function ToolsRoute() {
const { selectedTenantId } = useBench();
Expand Down
84 changes: 84 additions & 0 deletions apps/web/src/tools/registry-read.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
// Reads the tenant's published tool packages back off the stock registry
// surface `registry-publish.ts` writes to: the `corbits-tools`
// package-registry asset's tarball listing. There is no packument route —
// a tarball entry is only ever `<name>-<version>.tgz` (see
// `tarballFilenameFor` in `./registry-publish.ts`) — so name/version are
// recovered from the filename and nothing further (description, declared
// tools) is available without unpacking the tarball itself.

import { type } from "arktype";
import { useQuery } from "@tanstack/react-query";

import { ApiQueryError, UnauthenticatedError, toAPIQuery, type APIQuery } from "@/lib/api-query";

const AssetListShape = type({ id: "string", name: "string" }).array();
const TarballListShape = type({ filename: "string", size: "number", integrity: "string" }).array();

export type ToolPackage = {
readonly filename: string;
readonly name: string;
readonly version: string;
};

// Mirrors `tarballFilenameFor`: `<name-without-@-scope-slash>-<semver>.tgz`.
const TARBALL_NAME_PATTERN = /^(.+)-(\d+\.\d+\.\d+(?:[-+][0-9A-Za-z.-]+)?)\.tgz$/;

function parseTarballFilename(filename: string): ToolPackage | null {
const match = TARBALL_NAME_PATTERN.exec(filename);
if (match?.[1] === undefined || match[2] === undefined) return null;
return { filename, name: match[1], version: match[2] };
}

async function getJSON<T>(path: string, schema: (data: unknown) => T | type.errors): Promise<T> {
let response: Response;
try {
response = await fetch(path, { headers: { accept: "application/json" } });
} catch (cause) {
throw new ApiQueryError(
cause instanceof Error ? cause.message : String(cause),
undefined,
path,
);
}
if (response.status === 401) throw new UnauthenticatedError();
if (!response.ok) {
throw new ApiQueryError(`The server answered ${response.status}.`, response.status, path);
}
const parsed = schema(await response.json().catch(() => undefined));
if (parsed instanceof type.errors) {
throw new ApiQueryError(`Unexpected response shape: ${parsed.summary}`, undefined, path);
}
return parsed;
}

/** Every tarball published across the tenant's own package-registry assets
* (`inherited=false`: a tool package published to an ancestor tenant is
* that ancestor's own concern, not this bench's roster). */
async function listToolPackages(tenantId: string): Promise<readonly ToolPackage[]> {
const registries = await getJSON(
`/api/tenants/${tenantId}/assets?kind=package-registry&inherited=false`,
AssetListShape,
);
const perRegistry = await Promise.all(
registries.map((registry) =>
getJSON(
`/api/tenants/${tenantId}/assets/${encodeURIComponent(registry.id)}/tarballs`,
TarballListShape,
),
),
);
return perRegistry
.flat()
.map((tarball) => parseTarballFilename(tarball.filename))
.filter((pkg): pkg is ToolPackage => pkg !== null);
}

/** The tenant's published tool packages, for the Tools page. */
export function useToolPackages(tenantId: string | null): APIQuery<readonly ToolPackage[]> {
const result = useQuery({
queryKey: ["tenant", tenantId ?? "none", "tools", "packages"] as const,
enabled: tenantId !== null,
queryFn: () => listToolPackages(tenantId as string),
});
return toAPIQuery(result);
}
Loading