diff --git a/agents/myra/package.json b/agents/myra/package.json index 8605368cb..495e54e74 100644 --- a/agents/myra/package.json +++ b/agents/myra/package.json @@ -21,7 +21,8 @@ ".": "./src/index.ts", "./prompt": "./src/system-prompt.ts", "./workflow-ids": "./src/workflow-ids.ts", - "./bundle": "./src/bundle.ts" + "./bundle": "./src/bundle.ts", + "./package.json": "./package.json" }, "publishConfig": { "access": "public" diff --git a/apps/web/src/agent-source-read.ts b/apps/web/src/agent-source-read.ts index 6ffba3ff6..be9492d54 100644 --- a/apps/web/src/agent-source-read.ts +++ b/apps/web/src/agent-source-read.ts @@ -16,6 +16,8 @@ export class AgentSourceReadError extends Error {} const GitTokenMintShape = type({ id: "string", secret: "string" }); +const ToolPackagePinShape = type({ name: "string", version: "string" }); + const AgentWorkflowJsonShape = type({ id: "string", steps: type.Record( @@ -24,6 +26,7 @@ const AgentWorkflowJsonShape = type({ agent: type({ systemPrompt: "string", inference: { sources: type({ provider: "string", model: "string" }).array() }, + "toolPackagePins?": ToolPackagePinShape.array(), }), }), ), @@ -46,14 +49,20 @@ export type AgentSource = { readonly declaredSources: readonly { readonly provider: string; readonly model: string }[]; }; -/** Mints a read-only token, fetches the asset's `main`, and parses out the - * agent definition its source tree carries. */ -export async function readAgentSource( +export type AgentToolPackagePin = { readonly name: string; readonly version: string }; + +type AgentWorkflowStep = (typeof AgentWorkflowJsonShape.infer)["steps"][string]; + +/** Mints a read-only token, fetches the asset's `main` over its smart-HTTP + * git remote, and parses `definition.json` out of it. Shared by every + * reader below so each mints and revokes its own short-lived token rather + * than holding one open across a batch of assets. */ +async function readAgentWorkflowStep( tenantId: string, assetId: string, assetName: string, - fetchImpl: typeof fetch = fetch, -): Promise { + fetchImpl: typeof fetch, +): Promise { const tokensPath = `/api/tenants/${encodeURIComponent(tenantId)}/git-tokens`; const minted = await fetchImpl(tokensPath, { method: "POST", @@ -97,11 +106,38 @@ export async function readAgentSource( if (step === undefined) { throw new AgentSourceReadError("this agent's source has no steps to read a prompt from"); } - return { - systemPrompt: step.agent.systemPrompt, - declaredSources: step.agent.inference.sources, - }; + return step; } finally { await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { method: "DELETE" }); } } + +/** Mints a read-only token, fetches the asset's `main`, and parses out the + * agent definition its source tree carries. */ +export async function readAgentSource( + tenantId: string, + assetId: string, + assetName: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const step = await readAgentWorkflowStep(tenantId, assetId, assetName, fetchImpl); + return { + systemPrompt: step.agent.systemPrompt, + declaredSources: step.agent.inference.sources, + }; +} + +/** The tool packages an agent's own step pins in `definition.json`. Empty + * for an agent whose tools ride bundled into its `workflow.js` closure + * instead (Myra's mail/posix factories never surface here — see + * `deployed-tool-packages.ts`'s `MYRA_TOOL_PACKAGES`, derived from + * `@corbits/myra/package.json`'s own dependencies). */ +export async function readAgentToolPackagePins( + tenantId: string, + assetId: string, + assetName: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const step = await readAgentWorkflowStep(tenantId, assetId, assetName, fetchImpl); + return step.agent.toolPackagePins ?? []; +} diff --git a/apps/web/src/pages/tools-page.tsx b/apps/web/src/pages/tools-page.tsx index b21b38981..0aa22c6de 100644 --- a/apps/web/src/pages/tools-page.tsx +++ b/apps/web/src/pages/tools-page.tsx @@ -1,7 +1,8 @@ -// Tools: a standalone rail destination listing the tool packages this -// tenant has published into its own `corbits-tools` package-registry -// asset. 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 the +// tenant's live agent deployments actually carry — read off each agent's +// deployed `definition.json` (plus Myra's bundled mail/posix, which never +// lands in that file). No stock route lists a tenant's MCP servers yet, so +// this page has nothing to show for those until one exists. import { PageShell, @@ -16,16 +17,16 @@ import { import { QueryView } from "@/lib/api-query"; import { Plugs } from "@/lib/icons"; -import { useToolPackages } from "../tools/registry-read"; +import { useDeployedToolPackages } from "../tools/deployed-tool-packages"; import { useBench } from "../bench-context"; import { StageTopBar } from "../shell/stage-top-bar"; /** - * The tenant's published tool packages, read off the stock registry asset. + * The tool packages the tenant's live agent deployments carry. * `tenantId` is the tenant every read is scoped to. */ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) { - const query = useToolPackages(tenantId); + const query = useDeployedToolPackages(tenantId); const crumbs = [{ label: "Tools" }]; function stage(body: React.ReactNode) { @@ -54,7 +55,7 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) { } title="No tools yet" - description="A tool package gives every agent in this workbench a new capability. Publish one to see it here." + description="A tool package gives an agent in this workbench a new capability. Deploy an agent that carries one to see it here." /> ) : (
@@ -63,13 +64,17 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) { Tool package Version + Agents {toolPackages.map((tool) => ( - + {tool.name} - {tool.version} + {tool.version ?? "—"} + + {tool.agentNames.join(", ")} + ))} diff --git a/apps/web/src/shell/first-run-tour.tsx b/apps/web/src/shell/first-run-tour.tsx index 0995daf72..213356bf8 100644 --- a/apps/web/src/shell/first-run-tour.tsx +++ b/apps/web/src/shell/first-run-tour.tsx @@ -4,15 +4,17 @@ // profile never re-shows it for the wrong account, and finishing or // skipping both mark it seen for good — there is no "remind me later". -import Joyride, { type CallBackProps, STATUS, type Step } from "react-joyride"; +import Joyride, { ACTIONS, type CallBackProps, STATUS, type Step } from "react-joyride"; import { useState } from "react"; +import { reportError } from "@corbits/error-sink"; const STORAGE_PREFIX = "workbench.first-run-tour-seen"; function hasSeenTour(userId: string): boolean { try { return window.localStorage.getItem(`${STORAGE_PREFIX}:${userId}`) === "true"; - } catch { + } catch (error) { + reportError(error, { operation: "first_run_tour_read" }); return true; // Storage disabled: never nag with a tour that can't remember itself. } } @@ -20,7 +22,8 @@ function hasSeenTour(userId: string): boolean { function markTourSeen(userId: string): void { try { window.localStorage.setItem(`${STORAGE_PREFIX}:${userId}`, "true"); - } catch { + } catch (error) { + reportError(error, { operation: "first_run_tour_write" }); // Storage disabled or full — the tour just replays next visit. } } @@ -63,7 +66,15 @@ export function FirstRunTour({ userId }: { readonly userId: string }) { if (!run) return null; function handleCallback(data: CallBackProps) { - if (data.status === STATUS.FINISHED || data.status === STATUS.SKIPPED) { + // The tooltip's close (X) button fires action "close" without ever + // moving status to FINISHED or SKIPPED, so it has to be treated as a + // dismissal in its own right — otherwise closing the tour this way + // never persists and it replays on the next mount. + if ( + data.status === STATUS.FINISHED || + data.status === STATUS.SKIPPED || + data.action === ACTIONS.CLOSE + ) { markTourSeen(userId); } } diff --git a/apps/web/src/tools/deployed-tool-packages.ts b/apps/web/src/tools/deployed-tool-packages.ts new file mode 100644 index 000000000..e4439e683 --- /dev/null +++ b/apps/web/src/tools/deployed-tool-packages.ts @@ -0,0 +1,87 @@ +// The tenant's tool roster, read off what its agents actually carry rather +// than a registry that outlives the packages it once held. An agent's tool +// packages live in two places: pinned in its deployed `definition.json` +// (`readAgentToolPackagePins`), or — for Myra — bundled straight into her +// `workflow.js` closure, which never shows up in that file. Her package.json +// dependencies are the source of truth for that bundle instead of a literal +// copy that could drift from it. + +import { useQuery } from "@tanstack/react-query"; +import { reportError } from "@corbits/error-sink"; +import myraPackage from "@corbits/myra/package.json"; + +import { toAPIQuery, type APIQuery } from "@/lib/api-query"; + +import { isMyraAgent, listChatAgents, type ChatAgent } from "../chat/threads-api"; +import { readAgentToolPackagePins } from "../agent-source-read"; + +export type DeployedToolPackage = { + readonly name: string; + readonly version: string | null; + /** Agents carrying this package, by display name, deduped and sorted. */ + readonly agentNames: readonly string[]; +}; + +/** Myra's bundled tools: every `@intx/tools-*` dependency her package.json + * declares, at the version it pins there. */ +const MYRA_TOOL_PACKAGES: readonly { readonly name: string; readonly version: string }[] = + Object.entries(myraPackage.dependencies as Record) + .filter(([name]) => name.startsWith("@intx/tools-")) + .map(([name, version]) => ({ name, version })); + +function liveAgentsOf(agents: readonly ChatAgent[]): readonly ChatAgent[] { + return agents.filter((agent) => agent.liveAddress !== null); +} + +async function toolPackagesOf( + tenantId: string, + agent: ChatAgent, +): Promise { + if (isMyraAgent(agent)) return MYRA_TOOL_PACKAGES; + try { + return await readAgentToolPackagePins(tenantId, agent.id, agent.assetName); + } catch (error) { + reportError(error, { operation: "deployed_tool_packages_read" }); + return []; + } +} + +/** Every tool package carried by one of the tenant's live agent deployments, + * grouped by package name and joined against which agents carry it. An + * agent whose definition can't be read contributes nothing rather than + * failing the whole roster. */ +async function listDeployedToolPackages(tenantId: string): Promise { + const agents = liveAgentsOf(await listChatAgents(tenantId)); + const perAgent = await Promise.all( + agents.map(async (agent) => ({ agent, packages: await toolPackagesOf(tenantId, agent) })), + ); + + const byName = new Map }>(); + for (const { agent, packages } of perAgent) { + for (const pkg of packages) { + const entry = byName.get(pkg.name) ?? { version: pkg.version, agentNames: new Set() }; + entry.agentNames.add(agent.name); + byName.set(pkg.name, entry); + } + } + + return [...byName.entries()] + .map(([name, { version, agentNames }]) => ({ + name, + version, + agentNames: [...agentNames].sort((a, b) => a.localeCompare(b)), + })) + .sort((a, b) => a.name.localeCompare(b.name)); +} + +/** The tenant's deployed tool packages, for the Tools page. */ +export function useDeployedToolPackages( + tenantId: string | null, +): APIQuery { + const result = useQuery({ + queryKey: ["tenant", tenantId ?? "none", "tools", "deployed-packages"] as const, + enabled: tenantId !== null, + queryFn: () => listDeployedToolPackages(tenantId as string), + }); + return toAPIQuery(result); +} diff --git a/apps/web/src/tools/registry-read.ts b/apps/web/src/tools/registry-read.ts deleted file mode 100644 index 637000feb..000000000 --- a/apps/web/src/tools/registry-read.ts +++ /dev/null @@ -1,82 +0,0 @@ -// Reads the tenant's published tool packages off its `corbits-tools` -// package-registry asset's tarball listing. There is no packument route — -// a tarball entry is only ever `-.tgz` — 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`: `-.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(path: string, schema: (data: unknown) => T | type.errors): Promise { - 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 { - 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 { - const result = useQuery({ - queryKey: ["tenant", tenantId ?? "none", "tools", "packages"] as const, - enabled: tenantId !== null, - queryFn: () => listToolPackages(tenantId as string), - }); - return toAPIQuery(result); -}