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
111 changes: 111 additions & 0 deletions apps/web/src/agent-model-redeploy.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
import { describe, expect, test } from "bun:test";

import {
buildAgentRedeployInput,
needsAgentRedeployForProviderChange,
} from "./agent-model-redeploy";
import type { Grant } from "./settings/tenancy-api";

function grant(id: string, resource: string, effect: Grant["effect"]): Grant {
return {
id,
tenantId: "bench",
principalId: "principal",
resource,
action: "invoke",
effect,
origin: "invoker",
createdAt: "2026-01-01T00:00:00.000Z",
updatedAt: "2026-01-01T00:00:00.000Z",
};
}

describe("needsAgentRedeployForProviderChange", () => {
test("stays quiet when the agent already declares the current offering", () => {
expect(
needsAgentRedeployForProviderChange(
[{ provider: "anthropic", model: "claude-sonnet-4-5" }],
[{ provider: "anthropic", model: "claude-sonnet-4-5" }],
),
).toBe(false);
});

test("flags a provider change", () => {
expect(
needsAgentRedeployForProviderChange(
[{ provider: "anthropic", model: "claude-sonnet-4-5" }],
[{ provider: "openai", model: "gpt-5" }],
),
).toBe(true);
});

test("flags a model change on the same provider", () => {
expect(
needsAgentRedeployForProviderChange(
[{ provider: "openai", model: "gpt-5" }],
[{ provider: "openai", model: "gpt-5.5" }],
),
).toBe(true);
});

test("flags a changed fallback chain", () => {
expect(
needsAgentRedeployForProviderChange(
[
{ provider: "anthropic", model: "claude-sonnet-4-5" },
{ provider: "openai", model: "gpt-5" },
],
[{ provider: "anthropic", model: "claude-sonnet-4-5" }],
),
).toBe(true);
});

test("stays quiet when no provider is connected yet", () => {
expect(
needsAgentRedeployForProviderChange(
[{ provider: "anthropic", model: "claude-sonnet-4-5" }],
null,
),
).toBe(false);
});
});

describe("buildAgentRedeployInput", () => {
test("preserves the agent's config and marks a redeploy", () => {
expect(
buildAgentRedeployInput({
name: "Research Buddy",
slug: "research-buddy",
systemPrompt: "You are helpful.",
mcpHandles: ["exa"],
toolGrants: [],
}),
).toEqual({
name: "Research Buddy",
slug: "research-buddy",
systemPrompt: "You are helpful.",
mcpHandles: ["exa"],
toolEffects: [],
redeploy: true,
});
});

test("carries allow and deny while dropping ask-gated permissions", () => {
const input = buildAgentRedeployInput({
name: "Research Buddy",
slug: "research-buddy",
systemPrompt: "You are helpful.",
mcpHandles: [],
toolGrants: [
grant("allow-row", "tool:web_search", "allow"),
grant("ask-row", "tool:send_mail", "ask"),
grant("deny-row", "tool:delete_all", "deny"),
],
});
expect(input.toolEffects).toEqual([
{ resource: "tool:web_search", effect: "allow" },
{ resource: "tool:delete_all", effect: "deny" },
]);
expect(input.redeploy).toBe(true);
});
});
107 changes: 107 additions & 0 deletions apps/web/src/agent-model-redeploy.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,107 @@
// Re-deploys a hand-authored agent onto the workspace's current inference
// offering after the provider changed since its last deploy — the same
// `deployAgentSource` pipeline that created it, with its config carried
// over, so nothing the person set is lost in the move.
import {
agentSlugFromSourceAssetName,
deployAgentSource,
type DeployedAgent,
type NewAgentInput,
} from "./agent-deploy";
import { readAgentMcpHandles, readAgentSource } from "./agent-source-read";
import type { ChatAgent } from "./chat/threads-api";
import { resolveExistingOffering, type DeclaredSource } from "./onboarding/provider-connect-step";
import type { Grant } from "./settings/tenancy-api";
import { readWorkerToolGrants, toolEffectsToCarry } from "./worker-tool-grants";

export class AgentRedeployError extends Error {}

/** An inference source as either side of the comparison carries it: the
* agent's deployed declaration (plain strings off the source tree) and the
* workspace's current offering (plugin-typed) alike. */
export type InferenceSource = {
readonly provider: string;
readonly model: string;
};

/** True when the agent's declared inference sources no longer match the
* workspace's current offering — its runs still pin the old provider, so a
* redeploy is the only way onto the new one. Quiet when no provider is
* connected yet, since there is nothing to move onto. */
export function needsAgentRedeployForProviderChange(
declared: readonly InferenceSource[],
current: readonly InferenceSource[] | null,
): boolean {
if (current === null) return false;
return (
declared.length !== current.length ||
declared.some(
(source, index) =>
source.provider !== current[index]?.provider || source.model !== current[index]?.model,
)
);
}

/** The redeploy input that keeps the agent's config: its name and address
* slug, its instructions, its bound servers, and its non-ask permissions —
* ask-gated tools stay ask-gated by falling back to the deploy default. */
export function buildAgentRedeployInput(args: {
readonly name: string;
readonly slug: string;
readonly systemPrompt: string;
readonly mcpHandles: readonly string[];
readonly toolGrants: readonly Grant[];
}): NewAgentInput {
return {
name: args.name,
slug: args.slug,
systemPrompt: args.systemPrompt,
mcpHandles: args.mcpHandles,
toolEffects: toolEffectsToCarry(args.toolGrants),
redeploy: true,
};
}

/** The workspace's current offering, or null when no provider is connected. */
export function readCurrentOfferingSources(
tenantId: string,
fetchImpl: typeof fetch = fetch,
): Promise<readonly DeclaredSource[] | null> {
return resolveExistingOffering(tenantId, fetchImpl).then(
(offering) => offering?.declaredSources ?? null,
);
}

/**
* Re-deploys an existing agent onto the current offering, preserving its
* config. Fails closed for the default worker and anything this pipeline
* didn't deploy (no slug to keep the address stable under), and when no
* provider is connected yet (`deployAgentSource` throws there).
*/
export async function redeployAgentForProviderChange(
args: { readonly tenantId: string; readonly agent: ChatAgent },
fetchImpl: typeof fetch = fetch,
): Promise<DeployedAgent> {
const slug = agentSlugFromSourceAssetName(args.agent.assetName);
if (slug === null) {
throw new AgentRedeployError(`${args.agent.assetName} was not deployed by this workbench`);
}
const [source, mcpHandles, grants] = await Promise.all([
readAgentSource(args.tenantId, args.agent.id, args.agent.assetName, fetchImpl),
readAgentMcpHandles(args.tenantId, args.agent.id, args.agent.assetName, fetchImpl),
readWorkerToolGrants(args.tenantId, args.agent),
]);
return deployAgentSource(
{
tenantId: args.tenantId,
input: buildAgentRedeployInput({
name: args.agent.name,
slug,
systemPrompt: source.systemPrompt,
mcpHandles,
toolGrants: grants?.tools ?? [],
}),
},
fetchImpl,
);
}
6 changes: 6 additions & 0 deletions apps/web/src/pages/worker-page.css
Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,12 @@
color: var(--danger-ink);
}

.wp-redeploy {
margin-top: 0.75rem;
border-top: 1px solid var(--line);
padding-top: 0.25rem;
}

.wp-hint {
font-size: 12.5px;
color: var(--ink-3);
Expand Down
74 changes: 74 additions & 0 deletions apps/web/src/pages/worker-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,11 @@ import { PageLayout } from "../shell/page-layout";
import { useWorkerRole } from "../worker-role-query";
import { updateGrant, type Grant } from "../settings/tenancy-api";
import { agentSlugFromSourceAssetName, deployAgentSource } from "../agent-deploy";
import {
needsAgentRedeployForProviderChange,
readCurrentOfferingSources,
redeployAgentForProviderChange,
} from "../agent-model-redeploy";
import { readAgentMcpHandles, readAgentSource } from "../agent-source-read";
import {
TOOL_PREFIX,
Expand Down Expand Up @@ -263,6 +268,12 @@ function approvalsLabel(tally: ReturnType<typeof useApprovalTally>): string {
return `${once + always + denied + waiting} asked · ${once + always} allowed`;
}

function formatSources(
sources: readonly { readonly provider: string; readonly model: string }[],
): string {
return sources.map((source) => `${source.provider} · ${source.model}`).join(", ");
}

function DetailsCard({
tenantId,
agent,
Expand All @@ -280,6 +291,39 @@ function DetailsCard({
const thisWeek = (own ?? []).filter((run) => Date.parse(run.createdAt) >= weekStart);
const tally = useApprovalTally(tenantId, thisWeek.slice(0, 50));
const created = (own ?? []).map((run) => Date.parse(run.createdAt)).sort((a, b) => a - b)[0];

// Only agents this pipeline deployed can be re-deployed from here — only
// they carry a slug to keep the address stable under. The default worker's
// provider moves are handled where the provider is edited (Settings).
const queryClient = useQueryClient();
const redeployable = agentSlugFromSourceAssetName(agent.assetName) !== null;
const offering = useQuery({
queryKey: [...tenantKeys.agents(tenantId), "offering"],
queryFn: () => readCurrentOfferingSources(tenantId),
enabled: redeployable,
});
const declared = source.data?.declaredSources;
const current = offering.data;
const providerChanged =
redeployable &&
declared !== undefined &&
current !== undefined &&
needsAgentRedeployForProviderChange(declared, current);

// Re-deploying preserves the agent's config (instructions, servers,
// permissions) — `redeployAgentForProviderChange` carries it over, so the
// move onto the current provider loses nothing the person set.
const redeploy = useMutation({
mutationFn: () => redeployAgentForProviderChange({ tenantId, agent }),
onSuccess: async () => {
toast("Worker re-deployed onto the current provider");
await queryClient.invalidateQueries({ queryKey: tenantKeys.agents(tenantId) });
},
onError: (cause) => {
reportError(cause, { operation: "worker_provider_redeploy", tenantId });
},
});

return (
<section className="wp-card">
<h2 className="wp-h2--small">Details</h2>
Expand All @@ -303,6 +347,36 @@ function DetailsCard({
</>
)}
</dl>
{!providerChanged ? null : (
<div className="wp-redeploy">
<p className="wp-note">
The inference provider changed since this worker was deployed — it still runs on{" "}
{formatSources(declared ?? [])}, while the workspace now offers{" "}
{formatSources(current ?? [])}. Re-deploying moves it over and keeps its instructions,
servers, and permissions.
</p>
{redeploy.isError ? (
<p role="alert" className="wp-note wp-note-error">
{describeApiError(redeploy.error, "re-deploy this worker")}
</p>
) : null}
<div className="wp-save-row">
<ConfirmButton
size="sm"
disabled={redeploy.isPending}
confirmLabel={`Re-deploy onto ${formatSources(current ?? [])}`}
onConfirm={() => redeploy.mutate()}
>
{redeploy.isPending ? "Re-deploying…" : "Re-deploy"}
</ConfirmButton>
<span className="wp-hint">
{redeploy.isPending
? "Moving onto the current provider"
: "Re-deploys and restarts this worker"}
</span>
</div>
</div>
)}
</section>
);
}
Expand Down
Loading
Loading