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
162 changes: 22 additions & 140 deletions apps/web/src/agent-deploy.ts
Original file line number Diff line number Diff line change
@@ -1,33 +1,21 @@
// Deploys a hand-authored agent the same way the default worker deploys
// (`worker-deploy.ts`), generalized over {name, displayName, systemPrompt}.
import { renderBundledWorkflowSourceTree } from "@corbits/workflows/client";
import { installPackage } from "./install-package";
import { type } from "arktype";
import { reportError } from "@corbits/error-sink";

import {
artifactToolsCredentialBinding,
artifactToolsCredentialUseRequirement,
mcpServerCredentialBinding,
mcpServerCredentialUseRequirement,
memoryToolsCredentialBinding,
memoryToolsCredentialUseRequirement,
type McpServerDeployment,
} from "@corbits/worker/workflow-ids";
import type { ToolEffect } from "@corbits/worker/definition-json";
import type { McpServerDeployment } from "@corbits/worker/workflow-ids";

import { ensureAgentHubCredential } from "./agent-hub-credential";
import { personMailAddress } from "./mail-address";
import { listMcpServers, resolveWorkspaceTenantId, toMcpServerDeployment } from "./mcp-servers";
import type { McpServer } from "./mcp-servers";
import { renderWorkerSourceTree } from "./worker-deploy";
import { resolveExistingOffering } from "./onboarding/provider-connect-step";
import { isValidSlug, slugify } from "@/lib/slug";

/** A non-default tool permission (`ask` is the stock default) to carry onto
* a redeployed run. */
export type ToolEffect = {
readonly resource: string;
readonly effect: "allow" | "deny";
};
export type { ToolEffect };

export class AgentDeployError extends Error {}

Expand All @@ -45,118 +33,6 @@ async function readErrorBody(response: Response): Promise<string> {
return envelope instanceof type.errors ? `HTTP ${response.status}` : envelope.error.userMessage;
}

/** The exact `WorkflowDefinition` JSON for a single-step, mail-triggered,
* unbounded-turn agent — the same shape `buildWorkerDefinitionJson` produces,
* generalized over the caller's own name and system prompt. */
export function buildAgentDefinitionJson(args: {
slug: string;
systemPrompt: string;
triggerAddress: string;
declaredSources: readonly { readonly provider: string; readonly model: string }[];
hubCredentialId: string;
/** Workspace-catalog servers this agent binds — the same bindings and use
* requirements Worker carries, so remote tools stay ask-gated except the
* read-only ones the server itself annotates. */
mcpServers: readonly McpServerDeployment[];
/** Tool permissions the person set on the previous run, re-declared so the
* new run materializes them at its first trigger. */
toolEffects: readonly ToolEffect[];
}): unknown {
const stepId = "run";
return {
id: args.slug,
// `to` only feeds the deploy-time mail.address/mail.send grants; it is
// not how mail reaches this agent — that happens at its run address.
triggers: [{ type: "mail", to: args.triggerAddress }],
// Resolved at deploy into the `hub` handle the artifact and memory tools
// use, and granted to the run on the deployer's authority at its first
// trigger. One credential, one binding and one requirement per package.
credentialBindings: [
artifactToolsCredentialBinding(args.slug),
memoryToolsCredentialBinding(args.slug),
...args.mcpServers.map((server) => mcpServerCredentialBinding(server)),
],
grantRequirements: [
artifactToolsCredentialUseRequirement(args.hubCredentialId),
memoryToolsCredentialUseRequirement(args.hubCredentialId),
...args.mcpServers.map((server) => mcpServerCredentialUseRequirement(server.credentialId)),
...args.toolEffects.map((tool) => ({
resource: tool.resource,
action: "invoke",
effect: tool.effect,
source: "creator",
})),
],
steps: {
[stepId]: {
kind: "step",
id: stepId,
agent: {
id: stepId,
description: `The "${args.slug}" agent`,
systemPrompt: args.systemPrompt,
toolFactories: [],
capabilities: [],
inference: { sources: args.declaredSources.map((source) => ({ ...source })) },
toolPackagePins: [],
},
drainBehavior: "wait",
// No `timeout`: it stays armed across an approval park, so any
// finite value aborts a run waiting on a person to answer an
// ask-gated tool call (see packages/worker/src/index.ts).
triggers: "unbounded",
input: { from: "trigger.payload" },
},
},
stepOrder: [stepId],
};
}

// The bundle's `buildWorkerWorkflow` is generic over which agent it builds.
async function renderAgentSourceTree(
packageName: string,
args: {
readonly slug: string;
readonly systemPrompt: string;
readonly triggerAddress: string;
readonly declaredSources: readonly { readonly provider: string; readonly model: string }[];
readonly hubCredentialId: string;
readonly mcpServers: readonly McpServerDeployment[];
readonly toolEffects: readonly ToolEffect[];
},
): Promise<Record<string, string>> {
const { WORKER_BUNDLE_BUILD_EXPORT, WORKER_DIRECTORS_BUNDLE, WORKER_WORKFLOW_BUNDLE } =
await import("@corbits/worker/bundle");
return renderBundledWorkflowSourceTree({
packageName,
bundle: WORKER_WORKFLOW_BUNDLE,
directorsBundle: WORKER_DIRECTORS_BUNDLE,
buildExport: WORKER_BUNDLE_BUILD_EXPORT,
buildInput: {
workflowId: args.slug,
triggerAddress: args.triggerAddress,
inferencePreferences: args.declaredSources.map((source) => ({ ...source })),
systemPrompt: args.systemPrompt,
hubCredentialId: args.hubCredentialId,
// The workspace-catalog servers the New Agent dialog (or Worker's
// create-agent card) bound to this agent; the bundle wires their tools
// behind the deferred director, ask-gated except read-only ones.
mcpServers: args.mcpServers,
},
workflowJson: JSON.stringify(
buildAgentDefinitionJson({
slug: args.slug,
systemPrompt: args.systemPrompt,
triggerAddress: args.triggerAddress,
declaredSources: args.declaredSources,
hubCredentialId: args.hubCredentialId,
mcpServers: args.mcpServers,
toolEffects: args.toolEffects,
}),
),
});
}

/** Every created agent's source asset name, `agent-<slug>-source` — the one
* naming convention this pipeline owns, so callers can recognize a created
* agent's asset without a lookup. */
Expand Down Expand Up @@ -198,6 +74,8 @@ export type NewAgentInput = {
/** Tool permissions to re-declare on a redeploy, as creator-sourced grant
* requirements. */
readonly toolEffects?: readonly ToolEffect[];
/** Deploy anew even when nothing changed (a restart mints a fresh run). */
readonly redeploy?: boolean;
};

/** Maps requested workspace-catalog handles to deployments. Fails closed on
Expand Down Expand Up @@ -346,21 +224,25 @@ export async function deployAgentSource(
// Minted once the asset exists: the definition binds this credential by
// name and requires its use by id, so it must precede the source.
files: async (assetId) =>
renderAgentSourceTree(packageName, {
slug,
systemPrompt,
triggerAddress,
declaredSources: offering.declaredSources,
hubCredentialId: await ensureAgentHubCredential(
{ tenantId: args.tenantId, definitionId: slug, assetId },
fetchImpl,
),
mcpServers,
toolEffects: args.input.toolEffects ?? [],
}),
renderWorkerSourceTree(
packageName,
{
workflowId: slug,
systemPrompt,
triggerAddress,
inferencePreferences: offering.declaredSources,
hubCredentialId: await ensureAgentHubCredential(
{ tenantId: args.tenantId, definitionId: slug, assetId },
fetchImpl,
),
mcpServers,
},
args.input.toolEffects ?? [],
),
entry: "./workflow.js",
sourceOfferingIds: offering.sourceOfferingIds,
defaultSourceOfferingId: offering.defaultSourceOfferingId,
...(args.input.redeploy === true ? { redeploy: true } : {}),
});
if (args.input.schedule !== undefined) {
await scheduleAgentRun(args.tenantId, args.input.schedule, assetName, tenant.domain, fetchImpl);
Expand Down
29 changes: 24 additions & 5 deletions apps/web/src/install-package.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@
// asset, a short-lived git push token, the package's source pushed to
// `main`, then a deployment pinned to that commit. Identical files change
// nothing: `main` keeps its commit and the existing deployment is returned.
// The deploy input rides in the tree, so a changed entry or offering chain is
// a changed tree; the stock deployment listing exposes neither.
import { type } from "arktype";

import { pushSourceTree } from "./git-push";
Expand All @@ -11,7 +13,11 @@ export class InstallPackageError extends Error {}
const IdShape = type({ id: "string" });
const AssetListShape = type({ id: "string", name: "string" }).array();
const GitTokenShape = type({ id: "string", secret: "string" });
const DeploymentListShape = type({ id: "string", definitionAssetId: "string" }).array();
const DeploymentListShape = type({
id: "string",
definitionAssetId: "string",
status: "string",
}).array();
const ErrorEnvelope = type({ error: { "userMessage?": "string", "message?": "string" } });

const PUSH_TOKEN_LIFETIME_MS = 10 * 60 * 1000;
Expand All @@ -29,8 +35,12 @@ export type InstallPackageArgs = {
entry: string;
sourceOfferingIds: readonly string[];
defaultSourceOfferingId: string;
/** Always deploy anew, even for an identical tree: a restart mints a fresh run. */
redeploy?: boolean;
};

const INSTALL_INPUT_PATH = ".workbench-install.json";

async function readError(response: Response): Promise<string> {
const body: unknown = await response.json().catch(() => undefined);
const envelope = ErrorEnvelope(body);
Expand Down Expand Up @@ -90,8 +100,9 @@ async function latestDeploymentId(
`the deployment list came back an unexpected shape: ${parsed.summary}`,
);
}
// The route lists most recent first.
return parsed.find((deployment) => deployment.definitionAssetId === assetId)?.id ?? null;
// The route lists most recent first; a released or failed one is not reusable.
const latest = parsed.find((deployment) => deployment.definitionAssetId === assetId);
return latest?.status === "deployed" ? latest.id : null;
}

export async function installPackage(
Expand All @@ -101,7 +112,15 @@ export async function installPackage(
const args = { ...input, fetch: input.fetch.bind(globalThis) };
const base = `${args.origin}/api/tenants/${encodeURIComponent(args.tenantId)}`;
const assetId = await ensureWorkflowAsset(args, base);
const files = typeof args.files === "function" ? await args.files(assetId) : args.files;
const source = typeof args.files === "function" ? await args.files(assetId) : args.files;
const files = {
...source,
[INSTALL_INPUT_PATH]: JSON.stringify({
entry: args.entry,
sourceOfferingIds: args.sourceOfferingIds,
defaultSourceOfferingId: args.defaultSourceOfferingId,
}),
};

const minted = await args.fetch(
`${base}/git-tokens`,
Expand Down Expand Up @@ -133,7 +152,7 @@ export async function installPackage(
await args.fetch(`${base}/git-tokens/${encodeURIComponent(token.id)}`, { method: "DELETE" });
}

if (!pushed.changed) {
if (!pushed.changed && args.redeploy !== true) {
const existing = await latestDeploymentId(args, base, assetId);
if (existing !== null) {
return { assetId, commitSha: pushed.commitSha, deploymentId: existing };
Expand Down
19 changes: 3 additions & 16 deletions apps/web/src/settings/worker-model-redeploy.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
// allocation service re-resolves by those exact ids, so the old offering
// can only be retired once a redeploy has moved Worker onto the new one.
// This is the same stock deploy path the client runs at onboarding
// (`worker-deploy.ts`'s `deployWorkerSource`, `POST /workflows/deployments`),
// (`worker-deploy.ts`'s `deployWorkerSource`),
// just re-run here with one offering id swapped for another.

import { type } from "arktype";
Expand Down Expand Up @@ -114,22 +114,9 @@ export async function redeployWorkerForModelChange(
if (swapped === null) return false;

const tenantDomain = await resolveTenantDomain(input.tenantId, fetchImpl);
const deploy = await deployWorkerSource(
{ tenantId: input.tenantId, tenantDomain, ...swapped },
await deployWorkerSource(
{ tenantId: input.tenantId, tenantDomain, ...swapped, redeploy: true },
fetchImpl,
);
const deployed = await fetchImpl(
`/api/tenants/${encodeURIComponent(input.tenantId)}/workflows/deployments`,
{
method: "POST",
headers: { "content-type": "application/json" },
body: JSON.stringify(deploy),
},
);
if (!deployed.ok) {
throw new WorkerRedeployError(
`redeploying the worker onto the new model failed: ${await readErrorBody(deployed)}`,
);
}
return true;
}
9 changes: 4 additions & 5 deletions apps/web/src/workbench-create.ts
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,13 @@ export async function createWorkbench(input: CreateWorkbenchInput): Promise<stri
if (offering === null) {
throw new Error("Connect a model provider in Settings before starting a workbench.");
}
const deployInput = await deployWorkerSource({
await deployWorkerSource({
tenantId,
tenantDomain: domain,
sourceOfferingIds: offering.sourceOfferingIds,
defaultSourceOfferingId: offering.defaultSourceOfferingId,
declaredSources: offering.declaredSources,
});
await hub.deployWorkflow(tenantId, deployInput);

// Each picked bench agent joins the workbench the same way Worker does: its
// source is read back out of the bench and re-pushed into the child,
Expand Down Expand Up @@ -149,14 +148,14 @@ export async function redeployWorkbenchAgent(
"deploy",
);
}
const deployInput = await deployWorkerSource({
await deployWorkerSource({
tenantId: workbenchTenantId,
tenantDomain: tenant.domain,
sourceOfferingIds: offering.sourceOfferingIds,
defaultSourceOfferingId: offering.defaultSourceOfferingId,
declaredSources: offering.declaredSources,
redeploy: true,
});
await hub.deployWorkflow(workbenchTenantId, deployInput);
return;
}
const slug = agentSlugFromSourceAssetName(agent.assetName);
Expand All @@ -169,6 +168,6 @@ export async function redeployWorkbenchAgent(
const source = await readAgentSource(workbenchTenantId, agent.id, agent.assetName);
await deployAgentSource({
tenantId: workbenchTenantId,
input: { name: agent.name, systemPrompt: source.systemPrompt, slug },
input: { name: agent.name, systemPrompt: source.systemPrompt, slug, redeploy: true },
});
}
Loading
Loading