Add agent instructions bundle editing

Expose first-class instructions bundle APIs, preserve agent prompt bundles in portability flows, and replace the Agent Detail prompts tab with file-backed bundle editing while retiring bootstrap prompt UI.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Dotta
2026-03-17 13:42:00 -05:00
parent 827b09d7a5
commit e980c2ef64
16 changed files with 1482 additions and 138 deletions

View File

@@ -15,6 +15,8 @@ import {
testAdapterEnvironmentSchema,
type AgentSkillSnapshot,
type InstanceSchedulerHeartbeatAgent,
upsertAgentInstructionsFileSchema,
updateAgentInstructionsBundleSchema,
updateAgentPermissionsSchema,
updateAgentInstructionsPathSchema,
wakeAgentSchema,
@@ -27,6 +29,7 @@ import {
import { validate } from "../middleware/validate.js";
import {
agentService,
agentInstructionsService,
accessService,
approvalService,
companySkillService,
@@ -36,6 +39,7 @@ import {
issueService,
logActivity,
secretService,
syncInstructionsBundleConfigFromFilePath,
workspaceOperationService,
} from "../services/index.js";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
@@ -70,6 +74,7 @@ export function agentRoutes(db: Db) {
const heartbeat = heartbeatService(db);
const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db);
const instructions = agentInstructionsService();
const companySkills = companySkillService(db);
const workspaceOperations = workspaceOperationService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true";
@@ -140,6 +145,17 @@ export function agentRoutes(db: Db) {
throw forbidden("Only CEO or agent creators can modify other agents");
}
async function assertCanReadAgent(req: Request, targetAgent: { companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") return;
if (!req.actor.agentId) throw forbidden("Agent authentication required");
const actorAgent = await svc.getById(req.actor.agentId);
if (!actorAgent || actorAgent.companyId !== targetAgent.companyId) {
throw forbidden("Agent key cannot access another company");
}
}
async function resolveCompanyIdForAgentReference(req: Request): Promise<string | null> {
const companyIdQuery = req.query.companyId;
const requestedCompanyId =
@@ -1203,9 +1219,10 @@ export function agentRoutes(db: Db) {
nextAdapterConfig[adapterConfigKey] = resolveInstructionsFilePath(req.body.path, existingAdapterConfig);
}
const syncedAdapterConfig = syncInstructionsBundleConfigFromFilePath(existing, nextAdapterConfig);
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
nextAdapterConfig,
syncedAdapterConfig,
{ strictMode: strictSecretsMode },
);
const actor = getActorInfo(req);
@@ -1252,6 +1269,166 @@ export function agentRoutes(db: Db) {
});
});
router.get("/agents/:id/instructions-bundle", async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertCanReadAgent(req, existing);
res.json(await instructions.getBundle(existing));
});
router.patch("/agents/:id/instructions-bundle", validate(updateAgentInstructionsBundleSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertCanManageInstructionsPath(req, existing);
const actor = getActorInfo(req);
const { bundle, adapterConfig } = await instructions.updateBundle(existing, req.body);
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
adapterConfig,
{ strictMode: strictSecretsMode },
);
await svc.update(
id,
{ adapterConfig: normalizedAdapterConfig },
{
recordRevision: {
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
source: "instructions_bundle_patch",
},
},
);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "agent.instructions_bundle_updated",
entityType: "agent",
entityId: existing.id,
details: {
mode: bundle.mode,
rootPath: bundle.rootPath,
entryFile: bundle.entryFile,
clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate === true,
},
});
res.json(bundle);
});
router.get("/agents/:id/instructions-bundle/file", async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertCanReadAgent(req, existing);
const relativePath = typeof req.query.path === "string" ? req.query.path : "";
if (!relativePath.trim()) {
res.status(422).json({ error: "Query parameter 'path' is required" });
return;
}
res.json(await instructions.readFile(existing, relativePath));
});
router.put("/agents/:id/instructions-bundle/file", validate(upsertAgentInstructionsFileSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertCanManageInstructionsPath(req, existing);
const actor = getActorInfo(req);
const result = await instructions.writeFile(existing, req.body.path, req.body.content, {
clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate,
});
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId,
result.adapterConfig,
{ strictMode: strictSecretsMode },
);
await svc.update(
id,
{ adapterConfig: normalizedAdapterConfig },
{
recordRevision: {
createdByAgentId: actor.agentId,
createdByUserId: actor.actorType === "user" ? actor.actorId : null,
source: "instructions_bundle_file_put",
},
},
);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "agent.instructions_file_updated",
entityType: "agent",
entityId: existing.id,
details: {
path: result.file.path,
size: result.file.size,
clearLegacyPromptTemplate: req.body.clearLegacyPromptTemplate === true,
},
});
res.json(result.file);
});
router.delete("/agents/:id/instructions-bundle/file", async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
if (!existing) {
res.status(404).json({ error: "Agent not found" });
return;
}
await assertCanManageInstructionsPath(req, existing);
const relativePath = typeof req.query.path === "string" ? req.query.path : "";
if (!relativePath.trim()) {
res.status(422).json({ error: "Query parameter 'path' is required" });
return;
}
const actor = getActorInfo(req);
const result = await instructions.deleteFile(existing, relativePath);
await logActivity(db, {
companyId: existing.companyId,
actorType: actor.actorType,
actorId: actor.actorId,
agentId: actor.agentId,
runId: actor.runId,
action: "agent.instructions_file_deleted",
entityType: "agent",
entityId: existing.id,
details: {
path: relativePath,
},
});
res.json(result.bundle);
});
router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => {
const id = req.params.id as string;
const existing = await svc.getById(id);
@@ -1300,7 +1477,7 @@ export function agentRoutes(db: Db) {
effectiveAdapterConfig,
{ strictMode: strictSecretsMode },
);
patchData.adapterConfig = normalizedEffectiveAdapterConfig;
patchData.adapterConfig = syncInstructionsBundleConfigFromFilePath(existing, normalizedEffectiveAdapterConfig);
}
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {};