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:
@@ -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) ?? {};
|
||||
|
||||
Reference in New Issue
Block a user