Skip to content

Commit cc5fdf4

Browse files
committed
feat(web): new-workbench picker deploys chosen bench agents into the room (CL-8442)
1 parent 92f8ab2 commit cc5fdf4

5 files changed

Lines changed: 225 additions & 8 deletions

File tree

‎apps/web/src/agent-source-read.ts‎

Lines changed: 104 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,104 @@
1+
// Reads an existing agent's deploy source back out of its workflow asset,
2+
// so the new-workbench picker can re-push the same definition into a
3+
// child tenant (agent-deploy.ts already generalizes that push+deploy).
4+
// There is no stock file-read route for a workflow asset (only
5+
// package-registry tarballs get one), so this fetches `main` over the
6+
// asset's smart-HTTP git remote with a short-lived read-only token.
7+
import { parseWorkflowSourceEntry, WORKFLOW_SOURCE_ENTRY_PATH } from "@corbits/workflows";
8+
import { type } from "arktype";
9+
10+
import { fetchSourceFile } from "./git-fetch";
11+
12+
export class AgentSourceReadError extends Error {}
13+
14+
const GitTokenMintShape = type({ id: "string", secret: "string" });
15+
16+
const AgentWorkflowJsonShape = type({
17+
id: "string",
18+
steps: type.Record(
19+
"string",
20+
type({
21+
agent: type({
22+
systemPrompt: "string",
23+
inference: { sources: type({ provider: "string", model: "string" }).array() },
24+
}),
25+
}),
26+
),
27+
});
28+
29+
const READ_TOKEN_LIFETIME_MS = 10 * 60 * 1000;
30+
31+
async function readErrorBody(response: Response): Promise<string> {
32+
const body: unknown = await response.json().catch(() => undefined);
33+
const envelope = type({
34+
error: { code: "string", userMessage: "string", refId: "string" },
35+
})(body);
36+
return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage;
37+
}
38+
39+
/** An existing agent's deploy source, in the shape `deployAgentSource`'s
40+
* `NewAgentInput` needs plus the sources it declares for inference. */
41+
export type AgentSource = {
42+
readonly systemPrompt: string;
43+
readonly declaredSources: readonly { readonly provider: string; readonly model: string }[];
44+
};
45+
46+
/** Mints a read-only token, fetches the asset's `main`, and parses out the
47+
* agent definition its source tree carries. */
48+
export async function readAgentSource(
49+
tenantId: string,
50+
assetId: string,
51+
assetName: string,
52+
fetchImpl: typeof fetch = fetch,
53+
): Promise<AgentSource> {
54+
const tokensPath = `/api/tenants/${encodeURIComponent(tenantId)}/git-tokens`;
55+
const minted = await fetchImpl(tokensPath, {
56+
method: "POST",
57+
headers: { "content-type": "application/json" },
58+
body: JSON.stringify({
59+
name: `agent-read-${crypto.randomUUID()}`,
60+
resource: `asset:${assetId}`,
61+
refPattern: "refs/heads/main",
62+
actions: ["can_read"],
63+
expiresAt: new Date(Date.now() + READ_TOKEN_LIFETIME_MS).toISOString(),
64+
}),
65+
});
66+
if (!minted.ok) {
67+
throw new AgentSourceReadError(`minting a read token failed: ${await readErrorBody(minted)}`);
68+
}
69+
const token = GitTokenMintShape(await minted.json());
70+
if (token instanceof type.errors) {
71+
throw new AgentSourceReadError(
72+
`the read token came back an unexpected shape: ${token.summary}`,
73+
);
74+
}
75+
76+
const url = new URL(
77+
`/api/tenants/${encodeURIComponent(tenantId)}/assets/workflow/${assetName}.git`,
78+
globalThis.location.origin,
79+
).toString();
80+
try {
81+
const entryModule = await fetchSourceFile({
82+
url,
83+
token: token.secret,
84+
filepath: WORKFLOW_SOURCE_ENTRY_PATH,
85+
});
86+
const workflowJson = parseWorkflowSourceEntry(entryModule, assetId);
87+
const parsed = AgentWorkflowJsonShape(JSON.parse(workflowJson));
88+
if (parsed instanceof type.errors) {
89+
throw new AgentSourceReadError(
90+
`this agent's source came back an unexpected shape: ${parsed.summary}`,
91+
);
92+
}
93+
const step = Object.values(parsed.steps)[0];
94+
if (step === undefined) {
95+
throw new AgentSourceReadError("this agent's source has no steps to read a prompt from");
96+
}
97+
return {
98+
systemPrompt: step.agent.systemPrompt,
99+
declaredSources: step.agent.inference.sources,
100+
};
101+
} finally {
102+
await fetchImpl(`${tokensPath}/${encodeURIComponent(token.id)}`, { method: "DELETE" });
103+
}
104+
}

‎apps/web/src/chat/threads-api.ts‎

Lines changed: 18 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,9 @@ export type ChatAgent = {
3333
* redeploys, unlike a run id. */
3434
readonly id: string;
3535
readonly name: string;
36+
/** The workflow asset's raw name — what its source is pushed at, e.g.
37+
* for re-reading a deploy source; `name` above may be a display alias. */
38+
readonly assetName: string;
3639
/** Every address this agent has ever run under, releases included. */
3740
readonly addresses: readonly string[];
3841
/** The address of the agent's currently live run, or null when none is
@@ -70,6 +73,11 @@ function displayAgentName(definitionName: string): string {
7073
: definitionName;
7174
}
7275

76+
/** Myra is always in a new workbench and never a pickable option. */
77+
export function isMyraAgent(agent: Pick<ChatAgent, "assetName">): boolean {
78+
return agent.assetName === MYRA_SOURCE_CONFIG.assetName;
79+
}
80+
7381
export function agentInitials(name: string): string {
7482
const words = name.trim().split(/\s+/).filter(Boolean);
7583
const letters = words.slice(0, 2).map((word) => word[0] ?? "");
@@ -115,12 +123,16 @@ export async function listChatAgents(tenantId: string): Promise<readonly ChatAge
115123
byAsset.set(deployment.definitionAssetId, entry);
116124
}
117125

118-
return [...byAsset.entries()].map(([assetId, entry]) => ({
119-
id: assetId,
120-
name: displayAgentName(nameByAssetId.get(assetId) ?? assetId),
121-
addresses: [...entry.addresses],
122-
liveAddress: entry.liveAddress,
123-
}));
126+
return [...byAsset.entries()].map(([assetId, entry]) => {
127+
const assetName = nameByAssetId.get(assetId) ?? assetId;
128+
return {
129+
id: assetId,
130+
name: displayAgentName(assetName),
131+
assetName,
132+
addresses: [...entry.addresses],
133+
liveAddress: entry.liveAddress,
134+
};
135+
});
124136
}
125137

126138
/** The agent an `@name` first message picks, matched case-insensitively

‎apps/web/src/git-fetch.ts‎

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
// Reads one file out of a hub asset repo from the browser, mirroring
2+
// `git-push.ts`'s in-memory clone but for the read side: isomorphic-git's
3+
// own upload-pack wire (unlike its receive-pack) parses the hub's
4+
// pkt-lines fine, so no hand-rolled wire code is needed here.
5+
import LightningFS from "@isomorphic-git/lightning-fs";
6+
import { Buffer } from "buffer";
7+
import git from "isomorphic-git";
8+
import http from "isomorphic-git/http/web";
9+
10+
globalThis.Buffer ??= Buffer;
11+
12+
export class GitFetchError extends Error {}
13+
14+
const MAIN_REF = "refs/heads/main";
15+
16+
/** Fetches `main` and returns `filepath`'s contents as text. */
17+
export async function fetchSourceFile(args: {
18+
url: string;
19+
token: string;
20+
filepath: string;
21+
}): Promise<string> {
22+
const fs = new LightningFS(`workbench-fetch-${crypto.randomUUID()}`, { wipe: true });
23+
const dir = "/repo";
24+
await fs.promises.mkdir(dir);
25+
await git.init({ fs, dir, defaultBranch: "main" });
26+
try {
27+
await git.fetch({
28+
fs,
29+
http,
30+
dir,
31+
url: args.url,
32+
ref: MAIN_REF,
33+
singleBranch: true,
34+
depth: 1,
35+
tags: false,
36+
headers: { Authorization: `Bearer ${args.token}` },
37+
});
38+
const oid = await git.resolveRef({ fs, dir, ref: "FETCH_HEAD" });
39+
const { blob } = await git.readBlob({ fs, dir, oid, filepath: args.filepath });
40+
return new TextDecoder().decode(blob);
41+
} catch (cause) {
42+
throw new GitFetchError(cause instanceof Error ? cause.message : String(cause));
43+
}
44+
}

‎apps/web/src/pages/new-workbench-picker.tsx‎

Lines changed: 39 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,8 +5,9 @@
55
import { Button, toast } from "@corbits/react-ui";
66
import { PaperPlaneRight } from "@/lib/icons";
77
import { CHAT_STRINGS, WorkbenchLoadingState } from "@/chat";
8-
import { useRef, useState } from "react";
9-
import { useMutation, useQueryClient } from "@tanstack/react-query";
8+
import { isMyraAgent, listChatAgents } from "@/chat/threads-api";
9+
import { useMemo, useRef, useState } from "react";
10+
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1011
import { reportError } from "@corbits/error-sink";
1112

1213
import { useBench } from "../bench-context";
@@ -45,8 +46,25 @@ export function NewWorkbenchPickerRoute() {
4546
const queryClient = useQueryClient();
4647
const { selectedTenantId } = useBench();
4748
const [prompt, setPrompt] = useState("");
49+
const [selectedAgentIds, setSelectedAgentIds] = useState<readonly string[]>([]);
4850
const promptRef = useRef<HTMLTextAreaElement | null>(null);
4951

52+
const agentsQuery = useQuery({
53+
queryKey: ["bench-agents", selectedTenantId],
54+
queryFn: () => listChatAgents(selectedTenantId!),
55+
enabled: selectedTenantId !== null,
56+
});
57+
const pickableAgents = useMemo(
58+
() => (agentsQuery.data ?? []).filter((agent) => !isMyraAgent(agent)),
59+
[agentsQuery.data],
60+
);
61+
62+
function toggleAgent(id: string) {
63+
setSelectedAgentIds((current) =>
64+
current.includes(id) ? current.filter((existing) => existing !== id) : [...current, id],
65+
);
66+
}
67+
5068
const create = useMutation({
5169
mutationFn: ({
5270
benchTenantId,
@@ -59,6 +77,9 @@ export function NewWorkbenchPickerRoute() {
5977
benchTenantId,
6078
name: workbenchName(openingMessage ?? ""),
6179
...(openingMessage !== undefined ? { openingMessage } : {}),
80+
pickedAgents: pickableAgents
81+
.filter((agent) => selectedAgentIds.includes(agent.id))
82+
.map((agent) => ({ id: agent.id, name: agent.name, assetName: agent.assetName })),
6283
}),
6384
onSuccess: (tenantId, variables) => {
6485
void queryClient.invalidateQueries({
@@ -164,6 +185,22 @@ export function NewWorkbenchPickerRoute() {
164185
</div>
165186
</form>
166187

188+
{pickableAgents.length > 0 && (
189+
<fieldset className="new-workbench-agent-picker">
190+
<legend>Bring existing agents into this room</legend>
191+
{pickableAgents.map((agent) => (
192+
<label key={agent.id} className="new-workbench-agent-option">
193+
<input
194+
type="checkbox"
195+
checked={selectedAgentIds.includes(agent.id)}
196+
onChange={() => toggleAgent(agent.id)}
197+
/>
198+
{agent.name}
199+
</label>
200+
))}
201+
</fieldset>
202+
)}
203+
167204
<button
168205
type="button"
169206
className="new-workbench-empty-channel"

‎apps/web/src/workbench-create.ts‎

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
// resolved offering, which does inherit.
66

77
import { listRoomParticipants, sendToRoom } from "@/chat/threads-api";
8+
import { deployAgentSource } from "./agent-deploy";
9+
import { readAgentSource } from "./agent-source-read";
810
import { deployMyraSource } from "./myra-deploy";
911
import { createFetchStockHub } from "./needs-converge";
1012
import { resolveExistingOffering } from "./onboarding/provider-connect-step";
@@ -39,6 +41,13 @@ export type CreateWorkbenchInput = {
3941
readonly benchTenantId: string;
4042
readonly name: string;
4143
readonly openingMessage?: string;
44+
/** Existing bench agents to re-deploy into the room alongside Myra:
45+
* their source asset id, name, and asset name (for reading it back). */
46+
readonly pickedAgents?: readonly {
47+
readonly id: string;
48+
readonly name: string;
49+
readonly assetName: string;
50+
}[];
4251
};
4352

4453
/** Returns the new room's tenant id — its deep link is `/w/<id>`. */
@@ -75,6 +84,17 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise<stri
7584
declaredSources: offering.declaredSources,
7685
});
7786
await hub.deployWorkflow(tenantId, deployInput);
87+
88+
// Each picked bench agent joins the room the same way Myra does: its
89+
// source is read back out of the bench and re-pushed into the child,
90+
// since a child's deploy rejects the parent's inherited asset outright.
91+
for (const picked of input.pickedAgents ?? []) {
92+
const source = await readAgentSource(input.benchTenantId, picked.id, picked.assetName);
93+
await deployAgentSource({
94+
tenantId,
95+
input: { name: picked.name, systemPrompt: source.systemPrompt },
96+
});
97+
}
7898
} catch (cause) {
7999
throw failure(cause, "deploy");
80100
}

0 commit comments

Comments
 (0)