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
3 changes: 2 additions & 1 deletion agents/myra/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
54 changes: 45 additions & 9 deletions apps/web/src/agent-source-read.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -24,6 +26,7 @@ const AgentWorkflowJsonShape = type({
agent: type({
systemPrompt: "string",
inference: { sources: type({ provider: "string", model: "string" }).array() },
"toolPackagePins?": ToolPackagePinShape.array(),
}),
}),
),
Expand All @@ -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<AgentSource> {
fetchImpl: typeof fetch,
): Promise<AgentWorkflowStep> {
const tokensPath = `/api/tenants/${encodeURIComponent(tenantId)}/git-tokens`;
const minted = await fetchImpl(tokensPath, {
method: "POST",
Expand Down Expand Up @@ -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<AgentSource> {
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<readonly AgentToolPackagePin[]> {
const step = await readAgentWorkflowStep(tenantId, assetId, assetName, fetchImpl);
return step.agent.toolPackagePins ?? [];
}
25 changes: 15 additions & 10 deletions apps/web/src/pages/tools-page.tsx
Original file line number Diff line number Diff line change
@@ -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,
Expand All @@ -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) {
Expand Down Expand Up @@ -54,7 +55,7 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) {
<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."
description="A tool package gives an agent in this workbench a new capability. Deploy an agent that carries one to see it here."
/>
) : (
<div className="px-4 pb-5 sm:px-7">
Expand All @@ -63,13 +64,17 @@ export function ToolsPage({ tenantId }: { readonly tenantId: string | null }) {
<TableRow>
<TableHead>Tool package</TableHead>
<TableHead>Version</TableHead>
<TableHead>Agents</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{toolPackages.map((tool) => (
<TableRow key={tool.filename}>
<TableRow key={tool.name}>
<TableCell className="font-medium">{tool.name}</TableCell>
<TableCell className="text-muted-foreground">{tool.version}</TableCell>
<TableCell className="text-muted-foreground">{tool.version ?? "—"}</TableCell>
<TableCell className="text-muted-foreground">
{tool.agentNames.join(", ")}
</TableCell>
</TableRow>
))}
</TableBody>
Expand Down
19 changes: 15 additions & 4 deletions apps/web/src/shell/first-run-tour.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,23 +4,26 @@
// 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.
}
}

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.
}
}
Expand Down Expand Up @@ -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);
}
}
Expand Down
87 changes: 87 additions & 0 deletions apps/web/src/tools/deployed-tool-packages.ts
Original file line number Diff line number Diff line change
@@ -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<string, string>)
.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<readonly { readonly name: string; readonly version: string | null }[]> {
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<readonly DeployedToolPackage[]> {
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<string, { version: string | null; agentNames: Set<string> }>();
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<readonly DeployedToolPackage[]> {
const result = useQuery({
queryKey: ["tenant", tenantId ?? "none", "tools", "deployed-packages"] as const,
enabled: tenantId !== null,
queryFn: () => listDeployedToolPackages(tenantId as string),
});
return toAPIQuery(result);
}
82 changes: 0 additions & 82 deletions apps/web/src/tools/registry-read.ts

This file was deleted.

Loading