From 032d668d851ccc31d9f3b35811c33525bff7fdce Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 01:13:32 -0700 Subject: [PATCH 1/2] fix(web): tour shown once; Tools page lists the packages deployed agents carry (CL-8504) The onboarding tour's close (X) button fired react-joyride's action 'close' without moving status to FINISHED/SKIPPED, so dismissing it that way never persisted and it replayed on the next mount. Also route Tools off each live agent's deployed definition.json (plus Myra's bundled mail/posix, which never lands there) instead of the stale corbits-tools package-registry asset. --- agents/myra/package.json | 3 +- agents/myra/src/tool-packages.ts | 13 ++++ apps/web/src/agent-source-read.ts | 53 ++++++++++--- apps/web/src/pages/tools-page.tsx | 25 +++--- apps/web/src/shell/first-run-tour.tsx | 19 ++++- apps/web/src/tools/deployed-tool-packages.ts | 79 +++++++++++++++++++ apps/web/src/tools/registry-read.ts | 82 -------------------- 7 files changed, 168 insertions(+), 106 deletions(-) create mode 100644 agents/myra/src/tool-packages.ts create mode 100644 apps/web/src/tools/deployed-tool-packages.ts delete mode 100644 apps/web/src/tools/registry-read.ts diff --git a/agents/myra/package.json b/agents/myra/package.json index 8605368cb..ddfbf7684 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", + "./tool-packages": "./src/tool-packages.ts" }, "publishConfig": { "access": "public" diff --git a/agents/myra/src/tool-packages.ts b/agents/myra/src/tool-packages.ts new file mode 100644 index 000000000..9a1b1fec1 --- /dev/null +++ b/agents/myra/src/tool-packages.ts @@ -0,0 +1,13 @@ +// The tool packages Myra's `workflow.js` closure bundles inline (see +// `index.ts`'s `MYRA_TOOL_FACTORIES`). Every deployment carries these, but +// `toolPackagePins` in the deployed `definition.json` stays empty for them +// — the factories ride the closure, not a resolved pin — so a reader of +// that file alone can't see them. This literal list is the browser-safe +// mirror a UI can import instead; it must be kept in step with this +// package's own `@intx/tools-*` dependency versions. +export type MyraToolPackage = { readonly name: string; readonly version: string }; + +export const MYRA_TOOL_PACKAGES: readonly MyraToolPackage[] = [ + { name: "@intx/tools-mail", version: "0.3.0" }, + { name: "@intx/tools-posix", version: "0.3.0" }, +]; diff --git a/apps/web/src/agent-source-read.ts b/apps/web/src/agent-source-read.ts index 6ffba3ff6..2303b2620 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,37 @@ 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 + * `MYRA_TOOL_PACKAGES` in `@corbits/myra/tool-packages`). */ +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..cf88dec36 --- /dev/null +++ b/apps/web/src/tools/deployed-tool-packages.ts @@ -0,0 +1,79 @@ +// 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 (see +// `MYRA_TOOL_PACKAGES`). + +import { useQuery } from "@tanstack/react-query"; +import { reportError } from "@corbits/error-sink"; +import { MYRA_TOOL_PACKAGES } from "@corbits/myra/tool-packages"; + +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[]; +}; + +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); -} From 40ad1427051dc82ebb4a1a96b7152f03789c68f1 Mon Sep 17 00:00:00 2001 From: Sawyer Date: Fri, 18 Sep 2026 01:17:13 -0700 Subject: [PATCH 2/2] fix(web): derive Myra's tool packages from her package.json (CL-8504) A literal MYRA_TOOL_PACKAGES list in a new agents/myra/src/tool-packages.ts module was exactly the drift-prone duplicate the ground rules avoid, and it broke the agent-package-stays-trim rule. Derive the same list at read time from @intx/tools-* entries in @corbits/myra/package.json's own dependencies instead. --- agents/myra/package.json | 2 +- agents/myra/src/tool-packages.ts | 13 ------------- apps/web/src/agent-source-read.ts | 3 ++- apps/web/src/tools/deployed-tool-packages.ts | 14 +++++++++++--- 4 files changed, 14 insertions(+), 18 deletions(-) delete mode 100644 agents/myra/src/tool-packages.ts diff --git a/agents/myra/package.json b/agents/myra/package.json index ddfbf7684..495e54e74 100644 --- a/agents/myra/package.json +++ b/agents/myra/package.json @@ -22,7 +22,7 @@ "./prompt": "./src/system-prompt.ts", "./workflow-ids": "./src/workflow-ids.ts", "./bundle": "./src/bundle.ts", - "./tool-packages": "./src/tool-packages.ts" + "./package.json": "./package.json" }, "publishConfig": { "access": "public" diff --git a/agents/myra/src/tool-packages.ts b/agents/myra/src/tool-packages.ts deleted file mode 100644 index 9a1b1fec1..000000000 --- a/agents/myra/src/tool-packages.ts +++ /dev/null @@ -1,13 +0,0 @@ -// The tool packages Myra's `workflow.js` closure bundles inline (see -// `index.ts`'s `MYRA_TOOL_FACTORIES`). Every deployment carries these, but -// `toolPackagePins` in the deployed `definition.json` stays empty for them -// — the factories ride the closure, not a resolved pin — so a reader of -// that file alone can't see them. This literal list is the browser-safe -// mirror a UI can import instead; it must be kept in step with this -// package's own `@intx/tools-*` dependency versions. -export type MyraToolPackage = { readonly name: string; readonly version: string }; - -export const MYRA_TOOL_PACKAGES: readonly MyraToolPackage[] = [ - { name: "@intx/tools-mail", version: "0.3.0" }, - { name: "@intx/tools-posix", version: "0.3.0" }, -]; diff --git a/apps/web/src/agent-source-read.ts b/apps/web/src/agent-source-read.ts index 2303b2620..be9492d54 100644 --- a/apps/web/src/agent-source-read.ts +++ b/apps/web/src/agent-source-read.ts @@ -130,7 +130,8 @@ export async function readAgentSource( /** 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 - * `MYRA_TOOL_PACKAGES` in `@corbits/myra/tool-packages`). */ + * `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, diff --git a/apps/web/src/tools/deployed-tool-packages.ts b/apps/web/src/tools/deployed-tool-packages.ts index cf88dec36..e4439e683 100644 --- a/apps/web/src/tools/deployed-tool-packages.ts +++ b/apps/web/src/tools/deployed-tool-packages.ts @@ -2,12 +2,13 @@ // 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 (see -// `MYRA_TOOL_PACKAGES`). +// `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 { MYRA_TOOL_PACKAGES } from "@corbits/myra/tool-packages"; +import myraPackage from "@corbits/myra/package.json"; import { toAPIQuery, type APIQuery } from "@/lib/api-query"; @@ -21,6 +22,13 @@ export type DeployedToolPackage = { 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); }