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
104 changes: 104 additions & 0 deletions apps/web/src/agent-source-read.ts
Original file line number Diff line number Diff line change
@@ -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/client";
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<string> {
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<AgentSource> {
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" });
}
}
24 changes: 18 additions & 6 deletions apps/web/src/chat/threads-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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<ChatAgent, "assetName">): 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] ?? "");
Expand Down Expand Up @@ -115,12 +123,16 @@ export async function listChatAgents(tenantId: string): Promise<readonly ChatAge
byAsset.set(deployment.definitionAssetId, entry);
}

return [...byAsset.entries()].map(([assetId, entry]) => ({
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
Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/git-fetch.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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));
}
}
41 changes: 39 additions & 2 deletions apps/web/src/pages/new-workbench-picker.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -45,8 +46,25 @@ export function NewWorkbenchPickerRoute() {
const queryClient = useQueryClient();
const { selectedTenantId } = useBench();
const [prompt, setPrompt] = useState("");
const [selectedAgentIds, setSelectedAgentIds] = useState<readonly string[]>([]);
const promptRef = useRef<HTMLTextAreaElement | null>(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,
Expand All @@ -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({
Expand Down Expand Up @@ -164,6 +185,22 @@ export function NewWorkbenchPickerRoute() {
</div>
</form>

{pickableAgents.length > 0 && (
<fieldset className="new-workbench-agent-picker">
<legend>Bring existing agents into this room</legend>
{pickableAgents.map((agent) => (
<label key={agent.id} className="new-workbench-agent-option">
<input
type="checkbox"
checked={selectedAgentIds.includes(agent.id)}
onChange={() => toggleAgent(agent.id)}
/>
{agent.name}
</label>
))}
</fieldset>
)}

<button
type="button"
className="new-workbench-empty-channel"
Expand Down
20 changes: 20 additions & 0 deletions apps/web/src/workbench-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
// resolved offering, which does inherit.

import { listRoomParticipants, sendToRoom } from "@/chat/threads-api";
import { deployAgentSource } from "./agent-deploy";
import { readAgentSource } from "./agent-source-read";
import { deployMyraSource } from "./myra-deploy";
import { createFetchStockHub } from "./needs-converge";
import { resolveExistingOffering } from "./onboarding/provider-connect-step";
Expand Down Expand Up @@ -39,6 +41,13 @@ export type CreateWorkbenchInput = {
readonly benchTenantId: string;
readonly name: string;
readonly openingMessage?: string;
/** Existing bench agents to re-deploy into the room alongside Myra:
* their source asset id, name, and asset name (for reading it back). */
readonly pickedAgents?: readonly {
readonly id: string;
readonly name: string;
readonly assetName: string;
}[];
};

/** Returns the new room's tenant id — its deep link is `/w/<id>`. */
Expand Down Expand Up @@ -75,6 +84,17 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise<stri
declaredSources: offering.declaredSources,
});
await hub.deployWorkflow(tenantId, deployInput);

// Each picked bench agent joins the room the same way Myra does: its
// source is read back out of the bench and re-pushed into the child,
// since a child's deploy rejects the parent's inherited asset outright.
for (const picked of input.pickedAgents ?? []) {
const source = await readAgentSource(input.benchTenantId, picked.id, picked.assetName);
await deployAgentSource({
tenantId,
input: { name: picked.name, systemPrompt: source.systemPrompt },
});
}
} catch (cause) {
throw failure(cause, "deploy");
}
Expand Down
Loading