mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-11 21:59:38 +01:00
feat(agents): bulk edit connections and default models (#17215)
Add harness-scoped bulk connection and default-model edits to the agent roster, with compatible choices, change previews, per-agent save results, and retries. Use an icon-based harness filter, keep agent rows readable, remove the status selector and selection hint, and redirect legacy status URLs to the full roster. Preserve authorization, audit history, unrelated settings, and optimistic concurrency checks through the existing agent update route. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
46760cc588
commit
9bf7dc0c8f
28 files changed
+1245
-223
No files matched your search
@@ -334,6 +334,30 @@ describe("agent routes adapter validation", () => {
|
||||
await unregisterTestAdapter(missingAdapterType);
|
||||
});
|
||||
|
||||
it("routes narrow roster edits through authorization, config merging and audit history", async () => {
|
||||
const existing = { ...(await mockAgentService.getById()), adapterConfig: { model: "old", cwd: "/workspace" }, runtimeConfig: { heartbeat: { intervalSec: 900 } } };
|
||||
mockAgentService.getById.mockResolvedValue(existing);
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, base => request(base).patch(`/api/agents/${existing.id}`).send({ runtimeSettings: {
|
||||
harness: "codex_local", model: "new", expected: { model: "old", aiConnection: null },
|
||||
} }));
|
||||
expect(response.status, JSON.stringify(response.body)).toBe(200);
|
||||
expect(response.body.adapterConfig).toMatchObject({ model: "new", cwd: "/workspace" });
|
||||
expect(response.body.runtimeConfig).toEqual(existing.runtimeConfig);
|
||||
expect(mockAgentService.update).toHaveBeenCalledWith(existing.id, expect.not.objectContaining({ runtimeSettings: expect.anything() }), expect.objectContaining({ expectedConfiguration: expect.objectContaining({ adapterConfig: existing.adapterConfig }), recordRevision: expect.anything() }));
|
||||
expect(mockLogActivity).toHaveBeenCalledWith(expect.anything(), expect.objectContaining({ action: "agent.updated", entityId: existing.id }));
|
||||
});
|
||||
|
||||
it("refuses a roster edit without configuration permission", async () => {
|
||||
mockAccessService.decide.mockResolvedValue({ allowed: false, reason: "deny_missing_grant", explanation: "No configuration permission" });
|
||||
const app = await createApp();
|
||||
const response = await requestApp(app, base => request(base).patch("/api/agents/11111111-1111-4111-8111-111111111111").send({ runtimeSettings: {
|
||||
harness: "codex_local", model: "new", expected: { model: null, aiConnection: null },
|
||||
} }));
|
||||
expect(response.status).toBe(403);
|
||||
expect(mockAgentService.update).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await unregisterTestAdapter("external_test");
|
||||
await unregisterTestAdapter(missingAdapterType);
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { agentRuntimeSettingsPatchSchema, type AgentRuntimeSettingsPatch } from "@paperclipai/shared";
|
||||
import { prepareRuntimeSettingsPatch } from "../services/agent-runtime-settings.js";
|
||||
|
||||
const connection = { provider: "openai", method: "api_key", mode: "responsible_user" } as const;
|
||||
const existing = { adapterType: "paperclip_runner", adapterConfig: { provider: "codex", model: "old", cwd: "/workspace", env: { PRESERVE: "value" } }, runtimeConfig: { heartbeat: { intervalSec: 900 }, aiConnection: connection }, status: "paused" };
|
||||
const settings: AgentRuntimeSettingsPatch = { harness: "codex_local", model: "new", expected: { model: "old", aiConnection: connection } };
|
||||
|
||||
describe("roster runtime settings patches", () => {
|
||||
it("changes only the requested fields and retains unrelated runtime settings", () => {
|
||||
expect(prepareRuntimeSettingsPatch(existing, { runtimeSettings: settings })).toEqual({ adapterConfig: { model: "new" } });
|
||||
const next = { ...connection, method: "subscription" } as const;
|
||||
expect(prepareRuntimeSettingsPatch(existing, { runtimeSettings: { ...settings, model: undefined, aiConnection: next } })).toEqual({ runtimeConfig: { heartbeat: { intervalSec: 900 }, aiConnection: next } });
|
||||
expect(existing.adapterConfig.model).toBe("old");
|
||||
});
|
||||
it("rejects stale previews, harness switches, and mixed broad patches", () => {
|
||||
expect(() => prepareRuntimeSettingsPatch(existing, { runtimeSettings: { ...settings, harness: "claude_local" } })).toThrow("harness changed");
|
||||
expect(() => prepareRuntimeSettingsPatch(existing, { runtimeSettings: { ...settings, expected: { ...settings.expected, model: "stale" } } })).toThrow("connection or model changed");
|
||||
expect(() => prepareRuntimeSettingsPatch(existing, { runtimeSettings: settings, name: "Unexpected rename" })).toThrow("cannot be combined");
|
||||
expect(() => prepareRuntimeSettingsPatch({ ...existing, status: "terminated" }, { runtimeSettings: settings })).toThrow("cannot be updated");
|
||||
});
|
||||
it("requires an explicit edit and rejects extra fields inside the intent", () => {
|
||||
expect(agentRuntimeSettingsPatchSchema.safeParse({ harness: "codex_local", expected: settings.expected }).success).toBe(false);
|
||||
expect(agentRuntimeSettingsPatchSchema.safeParse({ ...settings, model: " " }).success).toBe(false);
|
||||
expect(agentRuntimeSettingsPatchSchema.safeParse({ ...settings, status: "idle" }).success).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -74,6 +74,7 @@ export interface ClaudeLoginContext {
|
||||
}
|
||||
|
||||
export interface UpdateAgentOptions {
|
||||
expectedConfiguration?: Pick<AgentConfigurationRecord, "adapterType" | "adapterConfig" | "runtimeConfig" | "defaultEnvironmentId">;
|
||||
recordRevision?: RevisionMetadata;
|
||||
allowBuiltInAgentMetadata?: boolean;
|
||||
allowPendingApprovalConfigUpdate?: boolean;
|
||||
|
||||
@@ -48,6 +48,15 @@ const support = await getEmbeddedPostgresTestSupport();
|
||||
async function hire(status = "idle") { return createAgentLifecycle(db).requestHire(companyId, { name: "Agent", status, adapterType: "process" }); }
|
||||
async function current(id: string) { return (await db.select().from(agents).where(eq(agents.id, id)))[0]!; }
|
||||
|
||||
it("rejects an outdated configuration snapshot under the write lock", async () => {
|
||||
const agent = await hire();
|
||||
const snapshot = await current(agent.id);
|
||||
const expectedConfiguration = { adapterType: snapshot.adapterType, adapterConfig: snapshot.adapterConfig, runtimeConfig: snapshot.runtimeConfig, defaultEnvironmentId: snapshot.defaultEnvironmentId };
|
||||
await createAgentLifecycle(db).updateConfiguration(agent.id, { runtimeConfig: { heartbeat: { intervalSec: 42 } } });
|
||||
await expect(createAgentLifecycle(db).updateConfiguration(agent.id, { runtimeConfig: { aiConnection: {} } }, { expectedConfiguration })).rejects.toThrow("configuration changed while saving");
|
||||
expect((await current(agent.id)).runtimeConfig).toEqual({ heartbeat: { intervalSec: 42 } });
|
||||
});
|
||||
|
||||
it("maps existing states without starting setup or clearing pause reasons", async () => {
|
||||
const rows = await db.insert(agents).values(["idle", "running", "error", "paused", "pending_approval", "terminated"].map(status => ({
|
||||
companyId, name: status, status, pauseReason: status === "paused" ? "budget" : null,
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { conflict } from "../../../errors.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { agents, type Db } from "@paperclipai/db";
|
||||
@@ -11,6 +13,10 @@ export async function updateAgentConfiguration(tx: Db, effects: LifecycleEffects
|
||||
assertConfigurationFields(data);
|
||||
const [current] = await tx.select().from(agents).where(eq(agents.id, id)).for("no key update");
|
||||
if (!current) return null;
|
||||
if (options?.expectedConfiguration && Object.entries(options.expectedConfiguration).some(([key, value]) =>
|
||||
!isDeepStrictEqual(current[key as keyof typeof current], value))) {
|
||||
throw conflict("Agent configuration changed while saving. Refresh the roster and review the changes again.");
|
||||
}
|
||||
const patch = await effects.prepareConfiguration(tx, current, data, options);
|
||||
assertConfigurationFields(patch);
|
||||
const changed = ["adapterType", "adapterConfig", "runtimeConfig", "defaultEnvironmentId"].some(key =>
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import { prepareRuntimeSettingsPatch } from "../services/agent-runtime-settings.js";
|
||||
import { completeSetupTokenAiConnection } from "../services/setup-token-ai-connection.js";
|
||||
import { cancellationRequestId } from "../services/native-runtime/native-cancellation-request.js";
|
||||
import { agentHarnessType, type AgentRunnerChoice } from "@paperclipai/shared";
|
||||
@@ -22,6 +23,8 @@ import { withManagedAiProbe, stripAiAuthBindings } from "../services/ai-connecti
|
||||
import {
|
||||
ADAPTER_AUTH_MISSING_CHECK_CODE,
|
||||
aiConnectionBindingSchema,
|
||||
aiConnectionMetadataSchema,
|
||||
aiRoutingModel,
|
||||
aiRuntimeConnectionBindingSchema,
|
||||
type AiConnectionBinding,
|
||||
type AiRuntimeConnectionBinding,
|
||||
@@ -3103,7 +3106,7 @@ export function agentRoutes(
|
||||
res.json(detected);
|
||||
});
|
||||
|
||||
async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record<string, unknown>, binding: AiRuntimeConnectionBinding, environmentId: string | null | undefined, test: boolean, newAgent = false) {
|
||||
async function validateManagedAgentBinding(req: Request, companyId: string, agentId: string, adapterType: string, config: Record<string, unknown>, binding: AiRuntimeConnectionBinding, environmentId: string | null | undefined, test: boolean, newAgent = false, validateModelCatalog = false) {
|
||||
if (binding.mode === "router") {
|
||||
const settings = await import("../services/instance-settings.js");
|
||||
if (!(await settings.instanceSettingsService(db).getExperimental()).enableAiConnectionRouters) throw unprocessable("Ask your instance operator to enable AI connection routing.");
|
||||
@@ -3143,6 +3146,11 @@ export function agentRoutes(
|
||||
throw error;
|
||||
});
|
||||
if (!selection) return undefined;
|
||||
const routing = validateModelCatalog ? aiConnectionMetadataSchema.parse(selection.connection.config.ai).routing : undefined;
|
||||
if (validateModelCatalog && routing?.models.length && !routing.models.some(model =>
|
||||
aiRoutingModel(routing, agentHarnessType(adapterType, config), model.id) === config.model)) {
|
||||
throw unprocessable("Choose a default model available on the selected connection");
|
||||
}
|
||||
// Copilot model availability is account-specific. Verify the selected
|
||||
// packaged runtime before creation/adoption even when a token is saved.
|
||||
// This metadata-only path sends no model prompt.
|
||||
@@ -5351,7 +5359,11 @@ export function agentRoutes(
|
||||
return;
|
||||
}
|
||||
|
||||
const patchData = { ...(req.body as Record<string, unknown>) };
|
||||
const runtimeSettings = req.body.runtimeSettings;
|
||||
if (runtimeSettings) await assertCanConfigureAgentSetup(req, existing);
|
||||
const patchData = runtimeSettings
|
||||
? prepareRuntimeSettingsPatch(existing, req.body)
|
||||
: { ...(req.body as Record<string, unknown>) };
|
||||
const runner = patchData.runner as AgentRunnerChoice | undefined;
|
||||
delete patchData.runner;
|
||||
const requestedStatus = patchData.status;
|
||||
@@ -5550,11 +5562,11 @@ export function agentRoutes(
|
||||
if (nextAiBinding.mode !== "router" && !isAiConnectionCompatible(nextAiBinding, requestedAdapterType, aiConfig.model, aiConfig.provider, aiConfig.acpxAgent)) throw unprocessable("Select an AI connection compatible with the new harness and model");
|
||||
const copilotConfigurationChanged = requestedAdapterType === "paperclip_runner" && aiConfig.provider === "acpx" && aiConfig.acpxAgent === "copilot"
|
||||
&& (touchesAdapterConfiguration || Object.prototype.hasOwnProperty.call(patchData, "defaultEnvironmentId"));
|
||||
if (changed || copilotConfigurationChanged || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) {
|
||||
if (runtimeSettings || changed || copilotConfigurationChanged || (nextAiBinding.mode === "router" && requestedAdapterType !== existing.adapterType)) {
|
||||
const configuringSetup = existing.lifecycleState === "paused" && existing.pauseReason === "setup";
|
||||
await validateManagedAgentBinding(req, existing.companyId, existing.id, requestedAdapterType, aiConfig, nextAiBinding,
|
||||
(patchData.defaultEnvironmentId !== undefined ? patchData.defaultEnvironmentId : existing.defaultEnvironmentId) as string | null,
|
||||
!configuringSetup, configuringSetup);
|
||||
!configuringSetup && (!runtimeSettings || changed), configuringSetup, Boolean(runtimeSettings));
|
||||
}
|
||||
}
|
||||
if (requestedRuntimeConfig) patchData.runtimeConfig = requestedRuntimeConfig;
|
||||
@@ -5582,6 +5594,10 @@ export function agentRoutes(
|
||||
|
||||
const actor = getActorInfo(req);
|
||||
const updateOptions = {
|
||||
...(runtimeSettings ? { expectedConfiguration: {
|
||||
adapterType: existing.adapterType, adapterConfig: existing.adapterConfig,
|
||||
runtimeConfig: existing.runtimeConfig, defaultEnvironmentId: existing.defaultEnvironmentId,
|
||||
} } : {}),
|
||||
recordRevision: {
|
||||
createdByAgentId: actor.agentId,
|
||||
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
import { isDeepStrictEqual } from "node:util";
|
||||
import { agentRuntimeSettingsHarness, type UpdateAgent } from "@paperclipai/shared";
|
||||
import type { AgentConfigurationRecord } from "../lib/agent-records.js";
|
||||
import { conflict, unprocessable } from "../errors.js";
|
||||
|
||||
/** Translate a scoped edit into the existing validated configuration write. */
|
||||
export function prepareRuntimeSettingsPatch(existing: Pick<AgentConfigurationRecord, "adapterType" | "adapterConfig" | "runtimeConfig" | "status">, input: UpdateAgent): Record<string, unknown> {
|
||||
const settings = input.runtimeSettings!;
|
||||
if (Object.keys(input).some(key => key !== "runtimeSettings")) {
|
||||
throw unprocessable("Runtime settings cannot be combined with other agent changes");
|
||||
}
|
||||
const harness = agentRuntimeSettingsHarness(existing.adapterType, existing.adapterConfig);
|
||||
if (harness !== settings.harness) throw conflict("This agent's harness changed. Refresh the roster and select it again.");
|
||||
if (["process", "http"].includes(harness) || ["terminated", "pending_approval"].includes(existing.status)) {
|
||||
throw unprocessable("This agent cannot be updated from the roster");
|
||||
}
|
||||
const model = typeof existing.adapterConfig.model === "string" ? existing.adapterConfig.model.trim() || null : null;
|
||||
const binding = existing.runtimeConfig.aiConnection ?? null;
|
||||
if (model !== settings.expected.model || !isDeepStrictEqual(binding, settings.expected.aiConnection)) {
|
||||
throw conflict("This agent's connection or model changed. Refresh the roster and review the changes again.");
|
||||
}
|
||||
if (settings.aiConnection?.mode === "router" || (binding && typeof binding === "object" && "mode" in binding && binding.mode === "router")) {
|
||||
throw unprocessable("Edit connection pool members on the agent's configuration page");
|
||||
}
|
||||
return {
|
||||
...(settings.model !== undefined ? { adapterConfig: { model: settings.model } } : {}),
|
||||
...(settings.aiConnection !== undefined ? { runtimeConfig: { ...existing.runtimeConfig, aiConnection: settings.aiConnection } } : {}),
|
||||
};
|
||||
}
|
||||
Reference in new issue
Block a user