From ad512c645b3b2d2848316895824371a78cf33a3d Mon Sep 17 00:00:00 2001 From: Sawyer Date: Thu, 17 Sep 2026 22:20:21 -0700 Subject: [PATCH 1/2] feat(web): new-workbench picker deploys chosen bench agents into the room (CL-8442) --- apps/web/src/agent-source-read.ts | 104 ++++++++++++++++++++ apps/web/src/chat/threads-api.ts | 24 +++-- apps/web/src/git-fetch.ts | 44 +++++++++ apps/web/src/pages/new-workbench-picker.tsx | 41 +++++++- apps/web/src/workbench-create.ts | 20 ++++ 5 files changed, 225 insertions(+), 8 deletions(-) create mode 100644 apps/web/src/agent-source-read.ts create mode 100644 apps/web/src/git-fetch.ts diff --git a/apps/web/src/agent-source-read.ts b/apps/web/src/agent-source-read.ts new file mode 100644 index 000000000..10f7d6b70 --- /dev/null +++ b/apps/web/src/agent-source-read.ts @@ -0,0 +1,104 @@ +// Reads an existing agent's deploy source back out of its workflow asset, +// so the new-workbench picker can re-push the same definition into a +// child tenant (agent-deploy.ts already generalizes that push+deploy). +// There is no stock file-read route for a workflow asset (only +// package-registry tarballs get one), so this fetches `main` over the +// asset's smart-HTTP git remote with a short-lived read-only token. +import { parseWorkflowSourceEntry, WORKFLOW_SOURCE_ENTRY_PATH } from "@corbits/workflows"; +import { type } from "arktype"; + +import { fetchSourceFile } from "./git-fetch"; + +export class AgentSourceReadError extends Error {} + +const GitTokenMintShape = type({ id: "string", secret: "string" }); + +const AgentWorkflowJsonShape = type({ + id: "string", + steps: type.Record( + "string", + type({ + agent: type({ + systemPrompt: "string", + inference: { sources: type({ provider: "string", model: "string" }).array() }, + }), + }), + ), +}); + +const READ_TOKEN_LIFETIME_MS = 10 * 60 * 1000; + +async function readErrorBody(response: Response): Promise { + const body: unknown = await response.json().catch(() => undefined); + const envelope = type({ + error: { code: "string", userMessage: "string", refId: "string" }, + })(body); + return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage; +} + +/** An existing agent's deploy source, in the shape `deployAgentSource`'s + * `NewAgentInput` needs plus the sources it declares for inference. */ +export type AgentSource = { + readonly systemPrompt: string; + 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( + tenantId: string, + assetId: string, + assetName: string, + fetchImpl: typeof fetch = fetch, +): Promise { + const tokensPath = `/api/tenants/${encodeURIComponent(tenantId)}/git-tokens`; + const minted = await fetchImpl(tokensPath, { + method: "POST", + headers: { "content-type": "application/json" }, + body: JSON.stringify({ + name: `agent-read-${crypto.randomUUID()}`, + resource: `asset:${assetId}`, + refPattern: "refs/heads/main", + actions: ["can_read"], + expiresAt: new Date(Date.now() + READ_TOKEN_LIFETIME_MS).toISOString(), + }), + }); + if (!minted.ok) { + throw new AgentSourceReadError(`minting a read token failed: ${await readErrorBody(minted)}`); + } + const token = GitTokenMintShape(await minted.json()); + if (token instanceof type.errors) { + throw new AgentSourceReadError( + `the read token came back an unexpected shape: ${token.summary}`, + ); + } + + const url = new URL( + `/api/tenants/${encodeURIComponent(tenantId)}/assets/workflow/${assetName}.git`, + globalThis.location.origin, + ).toString(); + try { + const entryModule = await fetchSourceFile({ + url, + token: token.secret, + filepath: WORKFLOW_SOURCE_ENTRY_PATH, + }); + const workflowJson = parseWorkflowSourceEntry(entryModule, assetId); + const parsed = AgentWorkflowJsonShape(JSON.parse(workflowJson)); + if (parsed instanceof type.errors) { + throw new AgentSourceReadError( + `this agent's source came back an unexpected shape: ${parsed.summary}`, + ); + } + const step = Object.values(parsed.steps)[0]; + 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, + }; + } finally { + await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { method: "DELETE" }); + } +} diff --git a/apps/web/src/chat/threads-api.ts b/apps/web/src/chat/threads-api.ts index fa92627d6..67be21bde 100644 --- a/apps/web/src/chat/threads-api.ts +++ b/apps/web/src/chat/threads-api.ts @@ -33,6 +33,9 @@ export type ChatAgent = { * redeploys, unlike a run id. */ readonly id: string; readonly name: string; + /** The workflow asset's raw name — what its source is pushed at, e.g. + * for re-reading a deploy source; `name` above may be a display alias. */ + readonly assetName: string; /** Every address this agent has ever run under, releases included. */ readonly addresses: readonly string[]; /** The address of the agent's currently live run, or null when none is @@ -70,6 +73,11 @@ function displayAgentName(definitionName: string): string { : definitionName; } +/** Myra is always in a new workbench and never a pickable option. */ +export function isMyraAgent(agent: Pick): boolean { + return agent.assetName === MYRA_SOURCE_CONFIG.assetName; +} + export function agentInitials(name: string): string { const words = name.trim().split(/\s+/).filter(Boolean); const letters = words.slice(0, 2).map((word) => word[0] ?? ""); @@ -115,12 +123,16 @@ export async function listChatAgents(tenantId: string): Promise ({ - id: assetId, - name: displayAgentName(nameByAssetId.get(assetId) ?? assetId), - addresses: [...entry.addresses], - liveAddress: entry.liveAddress, - })); + return [...byAsset.entries()].map(([assetId, entry]) => { + const assetName = nameByAssetId.get(assetId) ?? assetId; + return { + id: assetId, + name: displayAgentName(assetName), + assetName, + addresses: [...entry.addresses], + liveAddress: entry.liveAddress, + }; + }); } /** The agent an `@name` first message picks, matched case-insensitively diff --git a/apps/web/src/git-fetch.ts b/apps/web/src/git-fetch.ts new file mode 100644 index 000000000..3c02024cc --- /dev/null +++ b/apps/web/src/git-fetch.ts @@ -0,0 +1,44 @@ +// Reads one file out of a hub asset repo from the browser, mirroring +// `git-push.ts`'s in-memory clone but for the read side: isomorphic-git's +// own upload-pack wire (unlike its receive-pack) parses the hub's +// pkt-lines fine, so no hand-rolled wire code is needed here. +import LightningFS from "@isomorphic-git/lightning-fs"; +import { Buffer } from "buffer"; +import git from "isomorphic-git"; +import http from "isomorphic-git/http/web"; + +globalThis.Buffer ??= Buffer; + +export class GitFetchError extends Error {} + +const MAIN_REF = "refs/heads/main"; + +/** Fetches `main` and returns `filepath`'s contents as text. */ +export async function fetchSourceFile(args: { + url: string; + token: string; + filepath: string; +}): Promise { + const fs = new LightningFS(`workbench-fetch-${crypto.randomUUID()}`, { wipe: true }); + const dir = "/repo"; + await fs.promises.mkdir(dir); + await git.init({ fs, dir, defaultBranch: "main" }); + try { + await git.fetch({ + fs, + http, + dir, + url: args.url, + ref: MAIN_REF, + singleBranch: true, + depth: 1, + tags: false, + headers: { Authorization: `Bearer ${args.token}` }, + }); + const oid = await git.resolveRef({ fs, dir, ref: "FETCH_HEAD" }); + const { blob } = await git.readBlob({ fs, dir, oid, filepath: args.filepath }); + return new TextDecoder().decode(blob); + } catch (cause) { + throw new GitFetchError(cause instanceof Error ? cause.message : String(cause)); + } +} diff --git a/apps/web/src/pages/new-workbench-picker.tsx b/apps/web/src/pages/new-workbench-picker.tsx index 1cf394726..a153edac8 100644 --- a/apps/web/src/pages/new-workbench-picker.tsx +++ b/apps/web/src/pages/new-workbench-picker.tsx @@ -5,8 +5,9 @@ import { Button, toast } from "@corbits/react-ui"; import { PaperPlaneRight } from "@/lib/icons"; import { CHAT_STRINGS, WorkbenchLoadingState } from "@/chat"; -import { useRef, useState } from "react"; -import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { isMyraAgent, listChatAgents } from "@/chat/threads-api"; +import { useMemo, useRef, useState } from "react"; +import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { reportError } from "@corbits/error-sink"; import { useBench } from "../bench-context"; @@ -45,8 +46,25 @@ export function NewWorkbenchPickerRoute() { const queryClient = useQueryClient(); const { selectedTenantId } = useBench(); const [prompt, setPrompt] = useState(""); + const [selectedAgentIds, setSelectedAgentIds] = useState([]); const promptRef = useRef(null); + const agentsQuery = useQuery({ + queryKey: ["bench-agents", selectedTenantId], + queryFn: () => listChatAgents(selectedTenantId!), + enabled: selectedTenantId !== null, + }); + const pickableAgents = useMemo( + () => (agentsQuery.data ?? []).filter((agent) => !isMyraAgent(agent)), + [agentsQuery.data], + ); + + function toggleAgent(id: string) { + setSelectedAgentIds((current) => + current.includes(id) ? current.filter((existing) => existing !== id) : [...current, id], + ); + } + const create = useMutation({ mutationFn: ({ benchTenantId, @@ -59,6 +77,9 @@ export function NewWorkbenchPickerRoute() { benchTenantId, name: workbenchName(openingMessage ?? ""), ...(openingMessage !== undefined ? { openingMessage } : {}), + pickedAgents: pickableAgents + .filter((agent) => selectedAgentIds.includes(agent.id)) + .map((agent) => ({ id: agent.id, name: agent.name, assetName: agent.assetName })), }), onSuccess: (tenantId, variables) => { void queryClient.invalidateQueries({ @@ -164,6 +185,22 @@ export function NewWorkbenchPickerRoute() { + {pickableAgents.length > 0 && ( +
+ Bring existing agents into this room + {pickableAgents.map((agent) => ( + + ))} +
+ )} +