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

@@ -148,6 +148,10 @@ export type {
InstanceSettings, InstanceSettings,
Agent, Agent,
AgentPermissions, AgentPermissions,
AgentInstructionsBundleMode,
AgentInstructionsFileSummary,
AgentInstructionsFileDetail,
AgentInstructionsBundle,
AgentKeyCreated, AgentKeyCreated,
AgentConfigRevision, AgentConfigRevision,
AdapterEnvironmentCheckLevel, AdapterEnvironmentCheckLevel,
@@ -294,6 +298,9 @@ export {
createAgentSchema, createAgentSchema,
createAgentHireSchema, createAgentHireSchema,
updateAgentSchema, updateAgentSchema,
agentInstructionsBundleModeSchema,
updateAgentInstructionsBundleSchema,
upsertAgentInstructionsFileSchema,
updateAgentInstructionsPathSchema, updateAgentInstructionsPathSchema,
createAgentKeySchema, createAgentKeySchema,
wakeAgentSchema, wakeAgentSchema,
@@ -304,6 +311,8 @@ export {
type CreateAgent, type CreateAgent,
type CreateAgentHire, type CreateAgentHire,
type UpdateAgent, type UpdateAgent,
type UpdateAgentInstructionsBundle,
type UpsertAgentInstructionsFile,
type UpdateAgentInstructionsPath, type UpdateAgentInstructionsPath,
type CreateAgentKey, type CreateAgentKey,
type WakeAgent, type WakeAgent,

View File

@@ -9,6 +9,35 @@ export interface AgentPermissions {
canCreateAgents: boolean; canCreateAgents: boolean;
} }
export type AgentInstructionsBundleMode = "managed" | "external";
export interface AgentInstructionsFileSummary {
path: string;
size: number;
language: string;
markdown: boolean;
isEntryFile: boolean;
}
export interface AgentInstructionsFileDetail extends AgentInstructionsFileSummary {
content: string;
editable: boolean;
}
export interface AgentInstructionsBundle {
agentId: string;
companyId: string;
mode: AgentInstructionsBundleMode | null;
rootPath: string | null;
entryFile: string;
resolvedEntryPath: string | null;
editable: boolean;
warnings: string[];
legacyPromptTemplateActive: boolean;
legacyBootstrapPromptTemplateActive: boolean;
files: AgentInstructionsFileSummary[];
}
export interface Agent { export interface Agent {
id: string; id: string;
companyId: string; companyId: string;

View File

@@ -31,6 +31,10 @@ export type {
export type { export type {
Agent, Agent,
AgentPermissions, AgentPermissions,
AgentInstructionsBundleMode,
AgentInstructionsFileSummary,
AgentInstructionsFileDetail,
AgentInstructionsBundle,
AgentKeyCreated, AgentKeyCreated,
AgentConfigRevision, AgentConfigRevision,
AdapterEnvironmentCheckLevel, AdapterEnvironmentCheckLevel,

View File

@@ -11,6 +11,25 @@ export const agentPermissionsSchema = z.object({
canCreateAgents: z.boolean().optional().default(false), canCreateAgents: z.boolean().optional().default(false),
}); });
export const agentInstructionsBundleModeSchema = z.enum(["managed", "external"]);
export const updateAgentInstructionsBundleSchema = z.object({
mode: agentInstructionsBundleModeSchema.optional(),
rootPath: z.string().trim().min(1).nullable().optional(),
entryFile: z.string().trim().min(1).optional(),
clearLegacyPromptTemplate: z.boolean().optional().default(false),
});
export type UpdateAgentInstructionsBundle = z.infer<typeof updateAgentInstructionsBundleSchema>;
export const upsertAgentInstructionsFileSchema = z.object({
path: z.string().trim().min(1),
content: z.string(),
clearLegacyPromptTemplate: z.boolean().optional().default(false),
});
export type UpsertAgentInstructionsFile = z.infer<typeof upsertAgentInstructionsFileSchema>;
const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => { const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => {
const envValue = value.env; const envValue = value.env;
if (envValue === undefined) return; if (envValue === undefined) return;

View File

@@ -73,6 +73,9 @@ export {
createAgentSchema, createAgentSchema,
createAgentHireSchema, createAgentHireSchema,
updateAgentSchema, updateAgentSchema,
agentInstructionsBundleModeSchema,
updateAgentInstructionsBundleSchema,
upsertAgentInstructionsFileSchema,
updateAgentInstructionsPathSchema, updateAgentInstructionsPathSchema,
createAgentKeySchema, createAgentKeySchema,
wakeAgentSchema, wakeAgentSchema,
@@ -83,6 +86,8 @@ export {
type CreateAgent, type CreateAgent,
type CreateAgentHire, type CreateAgentHire,
type UpdateAgent, type UpdateAgent,
type UpdateAgentInstructionsBundle,
type UpsertAgentInstructionsFile,
type UpdateAgentInstructionsPath, type UpdateAgentInstructionsPath,
type CreateAgentKey, type CreateAgentKey,
type WakeAgent, type WakeAgent,

View File

@@ -0,0 +1,185 @@
import express from "express";
import request from "supertest";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { agentRoutes } from "../routes/agents.js";
import { errorHandler } from "../middleware/index.js";
const mockAgentService = vi.hoisted(() => ({
getById: vi.fn(),
update: vi.fn(),
resolveByReference: vi.fn(),
}));
const mockAgentInstructionsService = vi.hoisted(() => ({
getBundle: vi.fn(),
readFile: vi.fn(),
updateBundle: vi.fn(),
writeFile: vi.fn(),
deleteFile: vi.fn(),
exportFiles: vi.fn(),
ensureManagedBundle: vi.fn(),
materializeManagedBundle: vi.fn(),
}));
const mockAccessService = vi.hoisted(() => ({
canUser: vi.fn(),
hasPermission: vi.fn(),
}));
const mockSecretService = vi.hoisted(() => ({
resolveAdapterConfigForRuntime: vi.fn(),
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => ({}),
companySkillService: () => ({ listRuntimeSkillEntries: vi.fn() }),
budgetService: () => ({}),
heartbeatService: () => ({}),
issueApprovalService: () => ({}),
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => ({}),
}));
vi.mock("../adapters/index.js", () => ({
findServerAdapter: vi.fn(),
listAdapterModels: vi.fn(),
}));
function createApp() {
const app = express();
app.use(express.json());
app.use((req, _res, next) => {
(req as any).actor = {
type: "board",
userId: "local-board",
companyIds: ["company-1"],
source: "local_implicit",
isInstanceAdmin: false,
};
next();
});
app.use("/api", agentRoutes({} as any));
app.use(errorHandler);
return app;
}
function makeAgent() {
return {
id: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
name: "Agent",
role: "engineer",
title: "Engineer",
status: "active",
reportsTo: null,
capabilities: null,
adapterType: "codex_local",
adapterConfig: {},
runtimeConfig: {},
permissions: null,
updatedAt: new Date(),
};
}
describe("agent instructions bundle routes", () => {
beforeEach(() => {
vi.clearAllMocks();
mockAgentService.getById.mockResolvedValue(makeAgent());
mockAgentService.update.mockImplementation(async (_id: string, patch: Record<string, unknown>) => ({
...makeAgent(),
adapterConfig: patch.adapterConfig ?? {},
}));
mockAgentInstructionsService.getBundle.mockResolvedValue({
agentId: "11111111-1111-4111-8111-111111111111",
companyId: "company-1",
mode: "managed",
rootPath: "/tmp/agent-1",
entryFile: "AGENTS.md",
resolvedEntryPath: "/tmp/agent-1/AGENTS.md",
editable: true,
warnings: [],
legacyPromptTemplateActive: false,
legacyBootstrapPromptTemplateActive: false,
files: [{ path: "AGENTS.md", size: 12, language: "markdown", markdown: true, isEntryFile: true }],
});
mockAgentInstructionsService.readFile.mockResolvedValue({
path: "AGENTS.md",
size: 12,
language: "markdown",
markdown: true,
isEntryFile: true,
editable: true,
content: "# Agent\n",
});
mockAgentInstructionsService.writeFile.mockResolvedValue({
bundle: null,
file: {
path: "AGENTS.md",
size: 18,
language: "markdown",
markdown: true,
isEntryFile: true,
editable: true,
content: "# Updated Agent\n",
},
adapterConfig: {
instructionsBundleMode: "managed",
instructionsRootPath: "/tmp/agent-1",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
},
});
});
it("returns bundle metadata", async () => {
const res = await request(createApp())
.get("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle?companyId=company-1");
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(res.body).toMatchObject({
mode: "managed",
rootPath: "/tmp/agent-1",
entryFile: "AGENTS.md",
});
expect(mockAgentInstructionsService.getBundle).toHaveBeenCalled();
});
it("writes a bundle file and persists compatibility config", async () => {
const res = await request(createApp())
.put("/api/agents/11111111-1111-4111-8111-111111111111/instructions-bundle/file?companyId=company-1")
.send({
path: "AGENTS.md",
content: "# Updated Agent\n",
clearLegacyPromptTemplate: true,
});
expect(res.status, JSON.stringify(res.body)).toBe(200);
expect(mockAgentInstructionsService.writeFile).toHaveBeenCalledWith(
expect.objectContaining({ id: "11111111-1111-4111-8111-111111111111" }),
"AGENTS.md",
"# Updated Agent\n",
{ clearLegacyPromptTemplate: true },
);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterConfig: expect.objectContaining({
instructionsBundleMode: "managed",
instructionsRootPath: "/tmp/agent-1",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/agent-1/AGENTS.md",
}),
}),
expect.any(Object),
);
});
});

View File

@@ -20,6 +20,16 @@ const mockBudgetService = vi.hoisted(() => ({}));
const mockHeartbeatService = vi.hoisted(() => ({})); const mockHeartbeatService = vi.hoisted(() => ({}));
const mockIssueApprovalService = vi.hoisted(() => ({})); const mockIssueApprovalService = vi.hoisted(() => ({}));
const mockWorkspaceOperationService = vi.hoisted(() => ({})); const mockWorkspaceOperationService = vi.hoisted(() => ({}));
const mockAgentInstructionsService = vi.hoisted(() => ({
getBundle: vi.fn(),
readFile: vi.fn(),
updateBundle: vi.fn(),
writeFile: vi.fn(),
deleteFile: vi.fn(),
exportFiles: vi.fn(),
ensureManagedBundle: vi.fn(),
materializeManagedBundle: vi.fn(),
}));
const mockCompanySkillService = vi.hoisted(() => ({ const mockCompanySkillService = vi.hoisted(() => ({
listRuntimeSkillEntries: vi.fn(), listRuntimeSkillEntries: vi.fn(),
@@ -27,6 +37,7 @@ const mockCompanySkillService = vi.hoisted(() => ({
const mockSecretService = vi.hoisted(() => ({ const mockSecretService = vi.hoisted(() => ({
resolveAdapterConfigForRuntime: vi.fn(), resolveAdapterConfigForRuntime: vi.fn(),
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
})); }));
const mockLogActivity = vi.hoisted(() => vi.fn()); const mockLogActivity = vi.hoisted(() => vi.fn());
@@ -38,6 +49,7 @@ const mockAdapter = vi.hoisted(() => ({
vi.mock("../services/index.js", () => ({ vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService, agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService, accessService: () => mockAccessService,
approvalService: () => mockApprovalService, approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService, companySkillService: () => mockCompanySkillService,
@@ -47,6 +59,7 @@ vi.mock("../services/index.js", () => ({
issueService: () => ({}), issueService: () => ({}),
logActivity: mockLogActivity, logActivity: mockLogActivity,
secretService: () => mockSecretService, secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => mockWorkspaceOperationService, workspaceOperationService: () => mockWorkspaceOperationService,
})); }));

View File

@@ -36,6 +36,11 @@ const companySkillSvc = {
importPackageFiles: vi.fn(), importPackageFiles: vi.fn(),
}; };
const agentInstructionsSvc = {
exportFiles: vi.fn(),
materializeManagedBundle: vi.fn(),
};
vi.mock("../services/companies.js", () => ({ vi.mock("../services/companies.js", () => ({
companyService: () => companySvc, companyService: () => companySvc,
})); }));
@@ -60,6 +65,10 @@ vi.mock("../services/company-skills.js", () => ({
companySkillService: () => companySkillSvc, companySkillService: () => companySkillSvc,
})); }));
vi.mock("../services/agent-instructions.js", () => ({
agentInstructionsService: () => agentInstructionsSvc,
}));
const { companyPortabilityService } = await import("../services/company-portability.js"); const { companyPortabilityService } = await import("../services/company-portability.js");
describe("company portability", () => { describe("company portability", () => {
@@ -231,6 +240,21 @@ describe("company portability", () => {
}; };
}); });
companySkillSvc.importPackageFiles.mockResolvedValue([]); companySkillSvc.importPackageFiles.mockResolvedValue([]);
agentInstructionsSvc.exportFiles.mockImplementation(async (agent: { name: string }) => ({
files: { "AGENTS.md": agent.name === "CMO" ? "You are CMO." : "You are ClaudeCoder." },
entryFile: "AGENTS.md",
warnings: [],
}));
agentInstructionsSvc.materializeManagedBundle.mockImplementation(async (agent: { adapterConfig: Record<string, unknown> }) => ({
bundle: null,
adapterConfig: {
...agent.adapterConfig,
instructionsBundleMode: "managed",
instructionsRootPath: `/tmp/${agent.id}`,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: `/tmp/${agent.id}/AGENTS.md`,
},
}));
}); });
it("exports referenced skills as stubs by default with sanitized Paperclip extension data", async () => { it("exports referenced skills as stubs by default with sanitized Paperclip extension data", async () => {
@@ -536,14 +560,24 @@ describe("company portability", () => {
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterType: "codex_local", adapterType: "codex_local",
adapterConfig: expect.objectContaining({ adapterConfig: expect.objectContaining({
promptTemplate: "You are ClaudeCoder.",
dangerouslyBypassApprovalsAndSandbox: true, dangerouslyBypassApprovalsAndSandbox: true,
}), }),
})); }));
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({ expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterConfig: expect.not.objectContaining({ adapterConfig: expect.not.objectContaining({
instructionsFilePath: expect.anything(), instructionsFilePath: expect.anything(),
promptTemplate: expect.anything(),
}), }),
})); }));
expect(agentInstructionsSvc.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({ name: "ClaudeCoder" }),
expect.objectContaining({
"AGENTS.md": expect.stringContaining("You are ClaudeCoder."),
}),
expect.objectContaining({
clearLegacyPromptTemplate: true,
replaceExisting: true,
}),
);
}); });
}); });

View File

@@ -15,6 +15,8 @@ import {
testAdapterEnvironmentSchema, testAdapterEnvironmentSchema,
type AgentSkillSnapshot, type AgentSkillSnapshot,
type InstanceSchedulerHeartbeatAgent, type InstanceSchedulerHeartbeatAgent,
upsertAgentInstructionsFileSchema,
updateAgentInstructionsBundleSchema,
updateAgentPermissionsSchema, updateAgentPermissionsSchema,
updateAgentInstructionsPathSchema, updateAgentInstructionsPathSchema,
wakeAgentSchema, wakeAgentSchema,
@@ -27,6 +29,7 @@ import {
import { validate } from "../middleware/validate.js"; import { validate } from "../middleware/validate.js";
import { import {
agentService, agentService,
agentInstructionsService,
accessService, accessService,
approvalService, approvalService,
companySkillService, companySkillService,
@@ -36,6 +39,7 @@ import {
issueService, issueService,
logActivity, logActivity,
secretService, secretService,
syncInstructionsBundleConfigFromFilePath,
workspaceOperationService, workspaceOperationService,
} from "../services/index.js"; } from "../services/index.js";
import { conflict, forbidden, notFound, unprocessable } from "../errors.js"; import { conflict, forbidden, notFound, unprocessable } from "../errors.js";
@@ -70,6 +74,7 @@ export function agentRoutes(db: Db) {
const heartbeat = heartbeatService(db); const heartbeat = heartbeatService(db);
const issueApprovalsSvc = issueApprovalService(db); const issueApprovalsSvc = issueApprovalService(db);
const secretsSvc = secretService(db); const secretsSvc = secretService(db);
const instructions = agentInstructionsService();
const companySkills = companySkillService(db); const companySkills = companySkillService(db);
const workspaceOperations = workspaceOperationService(db); const workspaceOperations = workspaceOperationService(db);
const strictSecretsMode = process.env.PAPERCLIP_SECRETS_STRICT_MODE === "true"; 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"); 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> { async function resolveCompanyIdForAgentReference(req: Request): Promise<string | null> {
const companyIdQuery = req.query.companyId; const companyIdQuery = req.query.companyId;
const requestedCompanyId = const requestedCompanyId =
@@ -1203,9 +1219,10 @@ export function agentRoutes(db: Db) {
nextAdapterConfig[adapterConfigKey] = resolveInstructionsFilePath(req.body.path, existingAdapterConfig); nextAdapterConfig[adapterConfigKey] = resolveInstructionsFilePath(req.body.path, existingAdapterConfig);
} }
const syncedAdapterConfig = syncInstructionsBundleConfigFromFilePath(existing, nextAdapterConfig);
const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence( const normalizedAdapterConfig = await secretsSvc.normalizeAdapterConfigForPersistence(
existing.companyId, existing.companyId,
nextAdapterConfig, syncedAdapterConfig,
{ strictMode: strictSecretsMode }, { strictMode: strictSecretsMode },
); );
const actor = getActorInfo(req); 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) => { router.patch("/agents/:id", validate(updateAgentSchema), async (req, res) => {
const id = req.params.id as string; const id = req.params.id as string;
const existing = await svc.getById(id); const existing = await svc.getById(id);
@@ -1300,7 +1477,7 @@ export function agentRoutes(db: Db) {
effectiveAdapterConfig, effectiveAdapterConfig,
{ strictMode: strictSecretsMode }, { strictMode: strictSecretsMode },
); );
patchData.adapterConfig = normalizedEffectiveAdapterConfig; patchData.adapterConfig = syncInstructionsBundleConfigFromFilePath(existing, normalizedEffectiveAdapterConfig);
} }
if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") { if (touchesAdapterConfiguration && requestedAdapterType === "opencode_local") {
const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {}; const effectiveAdapterConfig = asRecord(patchData.adapterConfig) ?? {};

View File

@@ -0,0 +1,531 @@
import fs from "node:fs/promises";
import path from "node:path";
import { notFound, unprocessable } from "../errors.js";
import { resolveHomeAwarePath, resolvePaperclipInstanceRoot } from "../home-paths.js";
const ENTRY_FILE_DEFAULT = "AGENTS.md";
const MODE_KEY = "instructionsBundleMode";
const ROOT_KEY = "instructionsRootPath";
const ENTRY_KEY = "instructionsEntryFile";
const FILE_KEY = "instructionsFilePath";
const PROMPT_KEY = "promptTemplate";
const BOOTSTRAP_PROMPT_KEY = "bootstrapPromptTemplate";
type BundleMode = "managed" | "external";
type AgentLike = {
id: string;
companyId: string;
name: string;
adapterConfig: unknown;
};
type AgentInstructionsFileSummary = {
path: string;
size: number;
language: string;
markdown: boolean;
isEntryFile: boolean;
};
type AgentInstructionsFileDetail = AgentInstructionsFileSummary & {
content: string;
editable: boolean;
};
type AgentInstructionsBundle = {
agentId: string;
companyId: string;
mode: BundleMode | null;
rootPath: string | null;
entryFile: string;
resolvedEntryPath: string | null;
editable: boolean;
warnings: string[];
legacyPromptTemplateActive: boolean;
legacyBootstrapPromptTemplateActive: boolean;
files: AgentInstructionsFileSummary[];
};
type BundleState = {
config: Record<string, unknown>;
mode: BundleMode | null;
rootPath: string | null;
entryFile: string;
resolvedEntryPath: string | null;
warnings: string[];
legacyPromptTemplateActive: boolean;
legacyBootstrapPromptTemplateActive: boolean;
};
function asRecord(value: unknown): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) return {};
return value as Record<string, unknown>;
}
function asString(value: unknown): string | null {
if (typeof value !== "string") return null;
const trimmed = value.trim();
return trimmed.length > 0 ? trimmed : null;
}
function isBundleMode(value: unknown): value is BundleMode {
return value === "managed" || value === "external";
}
function inferLanguage(relativePath: string): string {
const lower = relativePath.toLowerCase();
if (lower.endsWith(".md")) return "markdown";
if (lower.endsWith(".json")) return "json";
if (lower.endsWith(".yaml") || lower.endsWith(".yml")) return "yaml";
if (lower.endsWith(".ts") || lower.endsWith(".tsx")) return "typescript";
if (lower.endsWith(".js") || lower.endsWith(".jsx") || lower.endsWith(".mjs") || lower.endsWith(".cjs")) {
return "javascript";
}
if (lower.endsWith(".sh")) return "bash";
if (lower.endsWith(".py")) return "python";
if (lower.endsWith(".toml")) return "toml";
if (lower.endsWith(".txt")) return "text";
return "text";
}
function isMarkdown(relativePath: string) {
return relativePath.toLowerCase().endsWith(".md");
}
function normalizeRelativeFilePath(candidatePath: string): string {
const normalized = path.posix.normalize(candidatePath.replaceAll("\\", "/")).replace(/^\/+/, "");
if (!normalized || normalized === "." || normalized === ".." || normalized.startsWith("../")) {
throw unprocessable("Instructions file path must stay within the bundle root");
}
return normalized;
}
function resolvePathWithinRoot(rootPath: string, relativePath: string): string {
const normalizedRelativePath = normalizeRelativeFilePath(relativePath);
const absoluteRoot = path.resolve(rootPath);
const absolutePath = path.resolve(absoluteRoot, normalizedRelativePath);
const relativeToRoot = path.relative(absoluteRoot, absolutePath);
if (relativeToRoot === ".." || relativeToRoot.startsWith(`..${path.sep}`)) {
throw unprocessable("Instructions file path must stay within the bundle root");
}
return absolutePath;
}
function resolveManagedInstructionsRoot(agent: AgentLike): string {
return path.resolve(
resolvePaperclipInstanceRoot(),
"companies",
agent.companyId,
"agents",
agent.id,
"instructions",
);
}
function resolveLegacyInstructionsPath(candidatePath: string, config: Record<string, unknown>): string {
if (path.isAbsolute(candidatePath)) return candidatePath;
const cwd = asString(config.cwd);
if (!cwd || !path.isAbsolute(cwd)) {
throw unprocessable(
"Legacy relative instructionsFilePath requires adapterConfig.cwd to be set to an absolute path",
);
}
return path.resolve(cwd, candidatePath);
}
async function statIfExists(targetPath: string) {
return fs.stat(targetPath).catch(() => null);
}
async function listFilesRecursive(rootPath: string): Promise<string[]> {
const output: string[] = [];
async function walk(currentPath: string, relativeDir: string) {
const entries = await fs.readdir(currentPath, { withFileTypes: true }).catch(() => []);
for (const entry of entries) {
if (entry.name === "." || entry.name === "..") continue;
const absolutePath = path.join(currentPath, entry.name);
const relativePath = normalizeRelativeFilePath(
relativeDir ? path.posix.join(relativeDir, entry.name) : entry.name,
);
if (entry.isDirectory()) {
await walk(absolutePath, relativePath);
continue;
}
if (!entry.isFile()) continue;
output.push(relativePath);
}
}
await walk(rootPath, "");
return output.sort((left, right) => left.localeCompare(right));
}
async function readFileSummary(rootPath: string, relativePath: string, entryFile: string): Promise<AgentInstructionsFileSummary> {
const absolutePath = resolvePathWithinRoot(rootPath, relativePath);
const stat = await fs.stat(absolutePath);
return {
path: relativePath,
size: stat.size,
language: inferLanguage(relativePath),
markdown: isMarkdown(relativePath),
isEntryFile: relativePath === entryFile,
};
}
async function readLegacyInstructions(agent: AgentLike, config: Record<string, unknown>): Promise<string> {
const instructionsFilePath = asString(config[FILE_KEY]);
if (instructionsFilePath) {
try {
const resolvedPath = resolveLegacyInstructionsPath(instructionsFilePath, config);
return await fs.readFile(resolvedPath, "utf8");
} catch {
// Fall back to promptTemplate below.
}
}
return asString(config[PROMPT_KEY]) ?? "";
}
function deriveBundleState(agent: AgentLike): BundleState {
const config = asRecord(agent.adapterConfig);
const warnings: string[] = [];
const storedModeRaw = config[MODE_KEY];
const storedRootRaw = asString(config[ROOT_KEY]);
const legacyInstructionsPath = asString(config[FILE_KEY]);
let mode: BundleMode | null = isBundleMode(storedModeRaw) ? storedModeRaw : null;
let rootPath = storedRootRaw ? resolveHomeAwarePath(storedRootRaw) : null;
let entryFile = ENTRY_FILE_DEFAULT;
const storedEntryRaw = asString(config[ENTRY_KEY]);
if (storedEntryRaw) {
try {
entryFile = normalizeRelativeFilePath(storedEntryRaw);
} catch {
warnings.push(`Ignored invalid instructions entry file "${storedEntryRaw}".`);
}
}
if (!rootPath && legacyInstructionsPath) {
try {
const resolvedLegacyPath = resolveLegacyInstructionsPath(legacyInstructionsPath, config);
rootPath = path.dirname(resolvedLegacyPath);
entryFile = path.basename(resolvedLegacyPath);
mode = resolvedLegacyPath.startsWith(`${resolveManagedInstructionsRoot(agent)}${path.sep}`)
|| resolvedLegacyPath === path.join(resolveManagedInstructionsRoot(agent), entryFile)
? "managed"
: "external";
if (!path.isAbsolute(legacyInstructionsPath)) {
warnings.push("Using legacy relative instructionsFilePath; migrate this agent to a managed or absolute external bundle.");
}
} catch (err) {
warnings.push(err instanceof Error ? err.message : String(err));
}
}
const resolvedEntryPath = rootPath ? path.resolve(rootPath, entryFile) : null;
return {
config,
mode,
rootPath,
entryFile,
resolvedEntryPath,
warnings,
legacyPromptTemplateActive: Boolean(asString(config[PROMPT_KEY])),
legacyBootstrapPromptTemplateActive: Boolean(asString(config[BOOTSTRAP_PROMPT_KEY])),
};
}
function toBundle(agent: AgentLike, state: BundleState, files: AgentInstructionsFileSummary[]): AgentInstructionsBundle {
return {
agentId: agent.id,
companyId: agent.companyId,
mode: state.mode,
rootPath: state.rootPath,
entryFile: state.entryFile,
resolvedEntryPath: state.resolvedEntryPath,
editable: Boolean(state.rootPath),
warnings: state.warnings,
legacyPromptTemplateActive: state.legacyPromptTemplateActive,
legacyBootstrapPromptTemplateActive: state.legacyBootstrapPromptTemplateActive,
files,
};
}
function applyBundleConfig(
config: Record<string, unknown>,
input: {
mode: BundleMode;
rootPath: string;
entryFile: string;
clearLegacyPromptTemplate?: boolean;
},
): Record<string, unknown> {
const next: Record<string, unknown> = {
...config,
[MODE_KEY]: input.mode,
[ROOT_KEY]: input.rootPath,
[ENTRY_KEY]: input.entryFile,
[FILE_KEY]: path.resolve(input.rootPath, input.entryFile),
};
if (input.clearLegacyPromptTemplate) {
delete next[PROMPT_KEY];
delete next[BOOTSTRAP_PROMPT_KEY];
}
return next;
}
export function syncInstructionsBundleConfigFromFilePath(
agent: AgentLike,
adapterConfig: Record<string, unknown>,
): Record<string, unknown> {
const instructionsFilePath = asString(adapterConfig[FILE_KEY]);
const next = { ...adapterConfig };
if (!instructionsFilePath) {
delete next[MODE_KEY];
delete next[ROOT_KEY];
delete next[ENTRY_KEY];
return next;
}
const resolvedPath = resolveLegacyInstructionsPath(instructionsFilePath, adapterConfig);
const rootPath = path.dirname(resolvedPath);
const entryFile = path.basename(resolvedPath);
const mode: BundleMode = resolvedPath.startsWith(`${resolveManagedInstructionsRoot(agent)}${path.sep}`)
|| resolvedPath === path.join(resolveManagedInstructionsRoot(agent), entryFile)
? "managed"
: "external";
return applyBundleConfig(next, { mode, rootPath, entryFile });
}
export function agentInstructionsService() {
async function getBundle(agent: AgentLike): Promise<AgentInstructionsBundle> {
const state = deriveBundleState(agent);
if (!state.rootPath) return toBundle(agent, state, []);
const stat = await statIfExists(state.rootPath);
if (!stat?.isDirectory()) {
return toBundle(agent, {
...state,
warnings: [...state.warnings, `Instructions root does not exist: ${state.rootPath}`],
}, []);
}
const files = await listFilesRecursive(state.rootPath);
const summaries = await Promise.all(files.map((relativePath) => readFileSummary(state.rootPath!, relativePath, state.entryFile)));
return toBundle(agent, state, summaries);
}
async function readFile(agent: AgentLike, relativePath: string): Promise<AgentInstructionsFileDetail> {
const state = deriveBundleState(agent);
if (!state.rootPath) throw notFound("Agent instructions bundle is not configured");
const absolutePath = resolvePathWithinRoot(state.rootPath, relativePath);
const [content, stat] = await Promise.all([
fs.readFile(absolutePath, "utf8").catch(() => null),
fs.stat(absolutePath).catch(() => null),
]);
if (content === null || !stat?.isFile()) throw notFound("Instructions file not found");
const normalizedPath = normalizeRelativeFilePath(relativePath);
return {
path: normalizedPath,
size: stat.size,
language: inferLanguage(normalizedPath),
markdown: isMarkdown(normalizedPath),
isEntryFile: normalizedPath === state.entryFile,
content,
editable: true,
};
}
async function ensureManagedBundle(
agent: AgentLike,
options?: { clearLegacyPromptTemplate?: boolean },
): Promise<{ adapterConfig: Record<string, unknown>; state: BundleState }> {
const current = deriveBundleState(agent);
if (current.rootPath && current.mode) {
return { adapterConfig: current.config, state: current };
}
const managedRoot = resolveManagedInstructionsRoot(agent);
const entryFile = current.entryFile || ENTRY_FILE_DEFAULT;
const nextConfig = applyBundleConfig(current.config, {
mode: "managed",
rootPath: managedRoot,
entryFile,
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
});
await fs.mkdir(managedRoot, { recursive: true });
const entryPath = resolvePathWithinRoot(managedRoot, entryFile);
const entryStat = await statIfExists(entryPath);
if (!entryStat?.isFile()) {
const legacyInstructions = await readLegacyInstructions(agent, current.config);
if (legacyInstructions.trim().length > 0) {
await fs.mkdir(path.dirname(entryPath), { recursive: true });
await fs.writeFile(entryPath, legacyInstructions, "utf8");
}
}
return {
adapterConfig: nextConfig,
state: deriveBundleState({ ...agent, adapterConfig: nextConfig }),
};
}
async function updateBundle(
agent: AgentLike,
input: {
mode?: BundleMode;
rootPath?: string | null;
entryFile?: string;
clearLegacyPromptTemplate?: boolean;
},
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
const state = deriveBundleState(agent);
const nextMode = input.mode ?? state.mode ?? "managed";
const nextEntryFile = input.entryFile ? normalizeRelativeFilePath(input.entryFile) : state.entryFile;
let nextRootPath: string;
if (nextMode === "managed") {
nextRootPath = resolveManagedInstructionsRoot(agent);
} else {
const rootPath = asString(input.rootPath) ?? state.rootPath;
if (!rootPath) {
throw unprocessable("External instructions bundles require an absolute rootPath");
}
const resolvedRoot = resolveHomeAwarePath(rootPath);
if (!path.isAbsolute(resolvedRoot)) {
throw unprocessable("External instructions bundles require an absolute rootPath");
}
nextRootPath = resolvedRoot;
}
await fs.mkdir(nextRootPath, { recursive: true });
const nextConfig = applyBundleConfig(state.config, {
mode: nextMode,
rootPath: nextRootPath,
entryFile: nextEntryFile,
clearLegacyPromptTemplate: input.clearLegacyPromptTemplate,
});
const nextBundle = await getBundle({ ...agent, adapterConfig: nextConfig });
return { bundle: nextBundle, adapterConfig: nextConfig };
}
async function writeFile(
agent: AgentLike,
relativePath: string,
content: string,
options?: { clearLegacyPromptTemplate?: boolean },
): Promise<{
bundle: AgentInstructionsBundle;
file: AgentInstructionsFileDetail;
adapterConfig: Record<string, unknown>;
}> {
const prepared = await ensureManagedBundle(agent, options);
const absolutePath = resolvePathWithinRoot(prepared.state.rootPath!, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, content, "utf8");
const nextAgent = { ...agent, adapterConfig: prepared.adapterConfig };
const [bundle, file] = await Promise.all([
getBundle(nextAgent),
readFile(nextAgent, relativePath),
]);
return { bundle, file, adapterConfig: prepared.adapterConfig };
}
async function deleteFile(agent: AgentLike, relativePath: string): Promise<{
bundle: AgentInstructionsBundle;
adapterConfig: Record<string, unknown>;
}> {
const state = deriveBundleState(agent);
if (!state.rootPath) throw notFound("Agent instructions bundle is not configured");
const normalizedPath = normalizeRelativeFilePath(relativePath);
if (normalizedPath === state.entryFile) {
throw unprocessable("Cannot delete the bundle entry file");
}
const absolutePath = resolvePathWithinRoot(state.rootPath, normalizedPath);
await fs.rm(absolutePath, { force: true });
const bundle = await getBundle(agent);
return { bundle, adapterConfig: state.config };
}
async function exportFiles(agent: AgentLike): Promise<{
files: Record<string, string>;
entryFile: string;
warnings: string[];
}> {
const state = deriveBundleState(agent);
if (state.rootPath) {
const stat = await statIfExists(state.rootPath);
if (stat?.isDirectory()) {
const relativePaths = await listFilesRecursive(state.rootPath);
const files = Object.fromEntries(await Promise.all(relativePaths.map(async (relativePath) => {
const absolutePath = resolvePathWithinRoot(state.rootPath!, relativePath);
const content = await fs.readFile(absolutePath, "utf8");
return [relativePath, content] as const;
})));
if (Object.keys(files).length > 0) {
return { files, entryFile: state.entryFile, warnings: state.warnings };
}
}
}
const legacyBody = await readLegacyInstructions(agent, state.config);
return {
files: { [state.entryFile]: legacyBody || "_No AGENTS instructions were resolved from current agent config._" },
entryFile: state.entryFile,
warnings: state.warnings,
};
}
async function materializeManagedBundle(
agent: AgentLike,
files: Record<string, string>,
options?: {
clearLegacyPromptTemplate?: boolean;
replaceExisting?: boolean;
entryFile?: string;
},
): Promise<{ bundle: AgentInstructionsBundle; adapterConfig: Record<string, unknown> }> {
const rootPath = resolveManagedInstructionsRoot(agent);
const entryFile = options?.entryFile ? normalizeRelativeFilePath(options.entryFile) : ENTRY_FILE_DEFAULT;
if (options?.replaceExisting) {
await fs.rm(rootPath, { recursive: true, force: true });
}
await fs.mkdir(rootPath, { recursive: true });
const normalizedEntries = Object.entries(files).map(([relativePath, content]) => [
normalizeRelativeFilePath(relativePath),
content,
] as const);
for (const [relativePath, content] of normalizedEntries) {
const absolutePath = resolvePathWithinRoot(rootPath, relativePath);
await fs.mkdir(path.dirname(absolutePath), { recursive: true });
await fs.writeFile(absolutePath, content, "utf8");
}
if (!normalizedEntries.some(([relativePath]) => relativePath === entryFile)) {
await fs.writeFile(resolvePathWithinRoot(rootPath, entryFile), "", "utf8");
}
const adapterConfig = applyBundleConfig(asRecord(agent.adapterConfig), {
mode: "managed",
rootPath,
entryFile,
clearLegacyPromptTemplate: options?.clearLegacyPromptTemplate,
});
const bundle = await getBundle({ ...agent, adapterConfig });
return { bundle, adapterConfig };
}
return {
getBundle,
readFile,
updateBundle,
writeFile,
deleteFile,
exportFiles,
ensureManagedBundle,
materializeManagedBundle,
};
}

View File

@@ -35,6 +35,7 @@ import {
import { notFound, unprocessable } from "../errors.js"; import { notFound, unprocessable } from "../errors.js";
import { accessService } from "./access.js"; import { accessService } from "./access.js";
import { agentService } from "./agents.js"; import { agentService } from "./agents.js";
import { agentInstructionsService } from "./agent-instructions.js";
import { generateReadme } from "./company-export-readme.js"; import { generateReadme } from "./company-export-readme.js";
import { companySkillService } from "./company-skills.js"; import { companySkillService } from "./company-skills.js";
import { companyService } from "./companies.js"; import { companyService } from "./companies.js";
@@ -380,7 +381,11 @@ function normalizePortableConfig(
if ( if (
key === "cwd" || key === "cwd" ||
key === "instructionsFilePath" || key === "instructionsFilePath" ||
key === "instructionsBundleMode" ||
key === "instructionsRootPath" ||
key === "instructionsEntryFile" ||
key === "promptTemplate" || key === "promptTemplate" ||
key === "bootstrapPromptTemplate" ||
key === "paperclipSkillSync" key === "paperclipSkillSync"
) continue; ) continue;
if (key === "env") continue; if (key === "env") continue;
@@ -1471,54 +1476,10 @@ function resolveRawGitHubUrl(owner: string, repo: string, ref: string, filePath:
return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${normalizedFilePath}`; return `https://raw.githubusercontent.com/${owner}/${repo}/${ref}/${normalizedFilePath}`;
} }
async function readAgentInstructions(agent: AgentLike): Promise<{ body: string; warning: string | null }> {
const config = agent.adapterConfig as Record<string, unknown>;
const instructionsFilePath = asString(config.instructionsFilePath);
if (instructionsFilePath) {
const workspaceCwd = asString(process.env.PAPERCLIP_WORKSPACE_CWD);
const candidates = new Set<string>();
if (path.isAbsolute(instructionsFilePath)) {
candidates.add(instructionsFilePath);
} else {
if (workspaceCwd) candidates.add(path.resolve(workspaceCwd, instructionsFilePath));
candidates.add(path.resolve(process.cwd(), instructionsFilePath));
}
for (const candidate of candidates) {
try {
const stat = await fs.stat(candidate);
if (!stat.isFile() || stat.size > 1024 * 1024) continue;
const body = await Promise.race([
fs.readFile(candidate, "utf8"),
new Promise<string>((_, reject) => {
setTimeout(() => reject(new Error("timed out reading instructions file")), 1500);
}),
]);
return { body, warning: null };
} catch {
// try next candidate
}
}
}
const promptTemplate = asString(config.promptTemplate);
if (promptTemplate) {
const warning = instructionsFilePath
? `Agent ${agent.name} instructionsFilePath was not readable; fell back to promptTemplate.`
: null;
return {
body: promptTemplate,
warning,
};
}
return {
body: "_No AGENTS instructions were resolved from current agent config._",
warning: `Agent ${agent.name} has no resolvable instructionsFilePath/promptTemplate; exported placeholder AGENTS.md.`,
};
}
export function companyPortabilityService(db: Db) { export function companyPortabilityService(db: Db) {
const companies = companyService(db); const companies = companyService(db);
const agents = agentService(db); const agents = agentService(db);
const instructions = agentInstructionsService();
const access = accessService(db); const access = accessService(db);
const projects = projectService(db); const projects = projectService(db);
const issues = issueService(db); const issues = issueService(db);
@@ -1783,9 +1744,8 @@ export function companyPortabilityService(db: Db) {
if (include.agents) { if (include.agents) {
for (const agent of agentRows) { for (const agent of agentRows) {
const slug = idToSlug.get(agent.id)!; const slug = idToSlug.get(agent.id)!;
const instructions = await readAgentInstructions(agent); const exportedInstructions = await instructions.exportFiles(agent);
if (instructions.warning) warnings.push(instructions.warning); warnings.push(...exportedInstructions.warnings);
const agentPath = `agents/${slug}/AGENTS.md`;
const envInputsStart = envInputs.length; const envInputsStart = envInputs.length;
const exportedEnvInputs = extractPortableEnvInputs( const exportedEnvInputs = extractPortableEnvInputs(
@@ -1825,16 +1785,22 @@ export function companyPortabilityService(db: Db) {
warnings.push(`Agent ${slug} command ${commandValue} was omitted from export because it is system-dependent.`); warnings.push(`Agent ${slug} command ${commandValue} was omitted from export because it is system-dependent.`);
delete portableAdapterConfig.command; delete portableAdapterConfig.command;
} }
for (const [relativePath, content] of Object.entries(exportedInstructions.files)) {
files[agentPath] = buildMarkdown( const targetPath = `agents/${slug}/${relativePath}`;
stripEmptyValues({ if (relativePath === exportedInstructions.entryFile) {
name: agent.name, files[targetPath] = buildMarkdown(
title: agent.title ?? null, stripEmptyValues({
reportsTo: reportsToSlug, name: agent.name,
skills: desiredSkills.length > 0 ? desiredSkills : undefined, title: agent.title ?? null,
}) as Record<string, unknown>, reportsTo: reportsToSlug,
instructions.body, skills: desiredSkills.length > 0 ? desiredSkills : undefined,
); }) as Record<string, unknown>,
content,
);
} else {
files[targetPath] = content;
}
}
const extension = stripEmptyValues({ const extension = stripEmptyValues({
role: agent.role !== "agent" ? agent.role : undefined, role: agent.role !== "agent" ? agent.role : undefined,
@@ -2346,26 +2312,39 @@ export function companyPortabilityService(db: Db) {
continue; continue;
} }
const markdownRaw = plan.source.files[manifestAgent.path]; const bundlePrefix = `agents/${manifestAgent.slug}/`;
if (!markdownRaw) { const bundleFiles = Object.fromEntries(
warnings.push(`Missing AGENTS markdown for ${manifestAgent.slug}; imported without prompt template.`); Object.entries(plan.source.files)
.filter(([filePath]) => filePath.startsWith(bundlePrefix))
.map(([filePath, content]) => [normalizePortablePath(filePath.slice(bundlePrefix.length)), content]),
);
const markdownRaw = bundleFiles["AGENTS.md"] ?? plan.source.files[manifestAgent.path];
const fallbackPromptTemplate = asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "";
if (!markdownRaw && fallbackPromptTemplate) {
bundleFiles["AGENTS.md"] = fallbackPromptTemplate;
}
if (!markdownRaw && !fallbackPromptTemplate) {
warnings.push(`Missing AGENTS markdown for ${manifestAgent.slug}; imported with an empty managed bundle.`);
} }
const markdown = markdownRaw ? parseFrontmatterMarkdown(markdownRaw) : { frontmatter: {}, body: "" };
const promptTemplate = markdown.body || asString((manifestAgent.adapterConfig as Record<string, unknown>).promptTemplate) || "";
// Apply adapter overrides from request if present // Apply adapter overrides from request if present
const adapterOverride = input.adapterOverrides?.[planAgent.slug]; const adapterOverride = input.adapterOverrides?.[planAgent.slug];
const effectiveAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType; const effectiveAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType;
const baseAdapterConfig = adapterOverride?.adapterConfig const baseAdapterConfig = adapterOverride?.adapterConfig
? { ...adapterOverride.adapterConfig, promptTemplate } ? { ...adapterOverride.adapterConfig }
: { ...manifestAgent.adapterConfig, promptTemplate } as Record<string, unknown>; : { ...manifestAgent.adapterConfig } as Record<string, unknown>;
const desiredSkills = manifestAgent.skills ?? []; const desiredSkills = manifestAgent.skills ?? [];
const adapterConfigWithSkills = writePaperclipSkillSyncPreference( const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
baseAdapterConfig, baseAdapterConfig,
desiredSkills, desiredSkills,
); );
delete adapterConfigWithSkills.promptTemplate;
delete adapterConfigWithSkills.bootstrapPromptTemplate;
delete adapterConfigWithSkills.instructionsFilePath; delete adapterConfigWithSkills.instructionsFilePath;
delete adapterConfigWithSkills.instructionsBundleMode;
delete adapterConfigWithSkills.instructionsRootPath;
delete adapterConfigWithSkills.instructionsEntryFile;
const patch = { const patch = {
name: planAgent.plannedName, name: planAgent.plannedName,
role: manifestAgent.role, role: manifestAgent.role,
@@ -2382,7 +2361,7 @@ export function companyPortabilityService(db: Db) {
}; };
if (planAgent.action === "update" && planAgent.existingAgentId) { if (planAgent.action === "update" && planAgent.existingAgentId) {
const updated = await agents.update(planAgent.existingAgentId, patch); let updated = await agents.update(planAgent.existingAgentId, patch);
if (!updated) { if (!updated) {
warnings.push(`Skipped update for missing agent ${planAgent.existingAgentId}.`); warnings.push(`Skipped update for missing agent ${planAgent.existingAgentId}.`);
resultAgents.push({ resultAgents.push({
@@ -2394,6 +2373,15 @@ export function companyPortabilityService(db: Db) {
}); });
continue; continue;
} }
try {
const materialized = await instructions.materializeManagedBundle(updated, bundleFiles, {
clearLegacyPromptTemplate: true,
replaceExisting: true,
});
updated = await agents.update(updated.id, { adapterConfig: materialized.adapterConfig }) ?? updated;
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
importedSlugToAgentId.set(planAgent.slug, updated.id); importedSlugToAgentId.set(planAgent.slug, updated.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id); existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id);
resultAgents.push({ resultAgents.push({
@@ -2406,7 +2394,16 @@ export function companyPortabilityService(db: Db) {
continue; continue;
} }
const created = await agents.create(targetCompany.id, patch); let created = await agents.create(targetCompany.id, patch);
try {
const materialized = await instructions.materializeManagedBundle(created, bundleFiles, {
clearLegacyPromptTemplate: true,
replaceExisting: true,
});
created = await agents.update(created.id, { adapterConfig: materialized.adapterConfig }) ?? created;
} catch (err) {
warnings.push(`Failed to materialize instructions bundle for ${manifestAgent.slug}: ${err instanceof Error ? err.message : String(err)}`);
}
importedSlugToAgentId.set(planAgent.slug, created.id); importedSlugToAgentId.set(planAgent.slug, created.id);
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id); existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
resultAgents.push({ resultAgents.push({

View File

@@ -1,6 +1,7 @@
export { companyService } from "./companies.js"; export { companyService } from "./companies.js";
export { companySkillService } from "./company-skills.js"; export { companySkillService } from "./company-skills.js";
export { agentService, deduplicateAgentName } from "./agents.js"; export { agentService, deduplicateAgentName } from "./agents.js";
export { agentInstructionsService, syncInstructionsBundleConfigFromFilePath } from "./agent-instructions.js";
export { assetService } from "./assets.js"; export { assetService } from "./assets.js";
export { documentService, extractLegacyPlanBody } from "./documents.js"; export { documentService, extractLegacyPlanBody } from "./documents.js";
export { projectService } from "./projects.js"; export { projectService } from "./projects.js";

View File

@@ -1,5 +1,7 @@
import type { import type {
Agent, Agent,
AgentInstructionsBundle,
AgentInstructionsFileDetail,
AgentSkillSnapshot, AgentSkillSnapshot,
AdapterEnvironmentTestResult, AdapterEnvironmentTestResult,
AgentKeyCreated, AgentKeyCreated,
@@ -103,6 +105,31 @@ export const agentsApi = {
api.patch<Agent>(agentPath(id, companyId), data), api.patch<Agent>(agentPath(id, companyId), data),
updatePermissions: (id: string, data: { canCreateAgents: boolean }, companyId?: string) => updatePermissions: (id: string, data: { canCreateAgents: boolean }, companyId?: string) =>
api.patch<Agent>(agentPath(id, companyId, "/permissions"), data), api.patch<Agent>(agentPath(id, companyId, "/permissions"), data),
instructionsBundle: (id: string, companyId?: string) =>
api.get<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle")),
updateInstructionsBundle: (
id: string,
data: {
mode?: "managed" | "external";
rootPath?: string | null;
entryFile?: string;
clearLegacyPromptTemplate?: boolean;
},
companyId?: string,
) => api.patch<AgentInstructionsBundle>(agentPath(id, companyId, "/instructions-bundle"), data),
instructionsFile: (id: string, relativePath: string, companyId?: string) =>
api.get<AgentInstructionsFileDetail>(
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
),
saveInstructionsFile: (
id: string,
data: { path: string; content: string; clearLegacyPromptTemplate?: boolean },
companyId?: string,
) => api.put<AgentInstructionsFileDetail>(agentPath(id, companyId, "/instructions-bundle/file"), data),
deleteInstructionsFile: (id: string, relativePath: string, companyId?: string) =>
api.delete<AgentInstructionsBundle>(
agentPath(id, companyId, `/instructions-bundle/file?path=${encodeURIComponent(relativePath)}`),
),
pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}), pause: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/pause"), {}),
resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}), resume: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/resume"), {}),
terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}), terminate: (id: string, companyId?: string) => api.post<Agent>(agentPath(id, companyId, "/terminate"), {}),

View File

@@ -735,36 +735,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
)} )}
</> </>
)} )}
<Field label="Bootstrap prompt (first run)" hint={help.bootstrapPrompt}>
<MarkdownEditor
value={
isCreate
? val!.bootstrapPrompt
: eff(
"adapterConfig",
"bootstrapPromptTemplate",
String(config.bootstrapPromptTemplate ?? ""),
)
}
onChange={(v) =>
isCreate
? set!({ bootstrapPrompt: v })
: mark("adapterConfig", "bootstrapPromptTemplate", v || undefined)
}
placeholder="Optional initial setup prompt for the first run"
contentClassName="min-h-[44px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = isCreate
? "agents/drafts/bootstrap-prompt"
: `agents/${props.agent.id}/bootstrap-prompt`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<div className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-2 text-xs text-sky-100">
Bootstrap prompt is only sent for fresh sessions. Put stable setup, habits, and longer reusable guidance here. Frequent changes reduce the value of session reuse because new sessions must replay it.
</div>
{adapterType === "claude_local" && ( {adapterType === "claude_local" && (
<ClaudeLocalAdvancedFields {...adapterFieldProps} /> <ClaudeLocalAdvancedFields {...adapterFieldProps} />
)} )}

View File

@@ -18,6 +18,9 @@ export const queryKeys = {
runtimeState: (id: string) => ["agents", "runtime-state", id] as const, runtimeState: (id: string) => ["agents", "runtime-state", id] as const,
taskSessions: (id: string) => ["agents", "task-sessions", id] as const, taskSessions: (id: string) => ["agents", "task-sessions", id] as const,
skills: (id: string) => ["agents", "skills", id] as const, skills: (id: string) => ["agents", "skills", id] as const,
instructionsBundle: (id: string) => ["agents", "instructions-bundle", id] as const,
instructionsFile: (id: string, relativePath: string) =>
["agents", "instructions-bundle", id, "file", relativePath] as const,
keys: (agentId: string) => ["agents", "keys", agentId] as const, keys: (agentId: string) => ["agents", "keys", agentId] as const,
configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const, configRevisions: (agentId: string) => ["agents", "config-revisions", agentId] as const,
adapterModels: (companyId: string, adapterType: string) => adapterModels: (companyId: string, adapterType: string) =>

View File

@@ -118,6 +118,10 @@ function redactEnvValue(key: string, value: unknown): string {
} }
} }
function isMarkdown(pathValue: string) {
return pathValue.toLowerCase().endsWith(".md");
}
function formatEnvForDisplay(envValue: unknown): string { function formatEnvForDisplay(envValue: unknown): string {
const env = asRecord(envValue); const env = asRecord(envValue);
if (!env) return "<unable-to-parse>"; if (!env) return "<unable-to-parse>";
@@ -1300,6 +1304,7 @@ function AgentConfigurePage({
updatePermissions={updatePermissions} updatePermissions={updatePermissions}
companyId={companyId} companyId={companyId}
hidePromptTemplate hidePromptTemplate
hideInstructionsFile
/> />
<div> <div>
<h3 className="text-sm font-medium mb-3">API Keys</h3> <h3 className="text-sm font-medium mb-3">API Keys</h3>
@@ -1371,6 +1376,7 @@ function ConfigurationTab({
onSavingChange, onSavingChange,
updatePermissions, updatePermissions,
hidePromptTemplate, hidePromptTemplate,
hideInstructionsFile,
}: { }: {
agent: Agent; agent: Agent;
companyId?: string; companyId?: string;
@@ -1380,6 +1386,7 @@ function ConfigurationTab({
onSavingChange: (saving: boolean) => void; onSavingChange: (saving: boolean) => void;
updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean }; updatePermissions: { mutate: (canCreate: boolean) => void; isPending: boolean };
hidePromptTemplate?: boolean; hidePromptTemplate?: boolean;
hideInstructionsFile?: boolean;
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false); const [awaitingRefreshAfterSave, setAwaitingRefreshAfterSave] = useState(false);
@@ -1434,6 +1441,7 @@ function ConfigurationTab({
onCancelActionChange={onCancelActionChange} onCancelActionChange={onCancelActionChange}
hideInlineSave hideInlineSave
hidePromptTemplate={hidePromptTemplate} hidePromptTemplate={hidePromptTemplate}
hideInstructionsFile={hideInstructionsFile}
sectionLayout="cards" sectionLayout="cards"
/> />
@@ -1479,13 +1487,16 @@ function PromptsTab({
}) { }) {
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const { selectedCompanyId } = useCompany(); const { selectedCompanyId } = useCompany();
const [selectedFile, setSelectedFile] = useState<string>("AGENTS.md");
const [draft, setDraft] = useState<string | null>(null); const [draft, setDraft] = useState<string | null>(null);
const [bundleDraft, setBundleDraft] = useState<{
mode: "managed" | "external";
rootPath: string;
entryFile: string;
} | null>(null);
const [newFilePath, setNewFilePath] = useState("");
const [awaitingRefresh, setAwaitingRefresh] = useState(false); const [awaitingRefresh, setAwaitingRefresh] = useState(false);
const lastAgentRef = useRef(agent); const lastFileVersionRef = useRef<string | null>(null);
const currentValue = String(agent.adapterConfig?.promptTemplate ?? "");
const displayValue = draft ?? currentValue;
const isDirty = draft !== null && draft !== currentValue;
const isLocal = const isLocal =
agent.adapterType === "claude_local" || agent.adapterType === "claude_local" ||
@@ -1495,10 +1506,60 @@ function PromptsTab({
agent.adapterType === "hermes_local" || agent.adapterType === "hermes_local" ||
agent.adapterType === "cursor"; agent.adapterType === "cursor";
const updateAgent = useMutation({ const { data: bundle, isLoading: bundleLoading } = useQuery({
mutationFn: (data: Record<string, unknown>) => agentsApi.update(agent.id, data, companyId), queryKey: queryKeys.agents.instructionsBundle(agent.id),
queryFn: () => agentsApi.instructionsBundle(agent.id, companyId),
enabled: Boolean(companyId && isLocal),
});
const currentMode = bundleDraft?.mode ?? bundle?.mode ?? "managed";
const currentEntryFile = bundleDraft?.entryFile ?? bundle?.entryFile ?? "AGENTS.md";
const currentRootPath = bundleDraft?.rootPath ?? bundle?.rootPath ?? "";
const fileOptions = bundle?.files.map((file) => file.path) ?? [];
const selectedOrEntryFile = selectedFile || currentEntryFile;
const selectedFileExists = fileOptions.includes(selectedOrEntryFile);
const { data: selectedFileDetail, isLoading: fileLoading } = useQuery({
queryKey: queryKeys.agents.instructionsFile(agent.id, selectedOrEntryFile),
queryFn: () => agentsApi.instructionsFile(agent.id, selectedOrEntryFile, companyId),
enabled: Boolean(companyId && isLocal && selectedFileExists),
});
const updateBundle = useMutation({
mutationFn: (data: {
mode?: "managed" | "external";
rootPath?: string | null;
entryFile?: string;
clearLegacyPromptTemplate?: boolean;
}) => agentsApi.updateInstructionsBundle(agent.id, data, companyId),
onMutate: () => setAwaitingRefresh(true), onMutate: () => setAwaitingRefresh(true),
onSuccess: () => { onSuccess: () => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
},
onError: () => setAwaitingRefresh(false),
});
const saveFile = useMutation({
mutationFn: (data: { path: string; content: string; clearLegacyPromptTemplate?: boolean }) =>
agentsApi.saveInstructionsFile(agent.id, data, companyId),
onMutate: () => setAwaitingRefresh(true),
onSuccess: (_, variables) => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, variables.path) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
},
onError: () => setAwaitingRefresh(false),
});
const deleteFile = useMutation({
mutationFn: (relativePath: string) => agentsApi.deleteInstructionsFile(agent.id, relativePath, companyId),
onMutate: () => setAwaitingRefresh(true),
onSuccess: (_, relativePath) => {
queryClient.invalidateQueries({ queryKey: queryKeys.agents.instructionsBundle(agent.id) });
queryClient.removeQueries({ queryKey: queryKeys.agents.instructionsFile(agent.id, relativePath) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.id) });
queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) }); queryClient.invalidateQueries({ queryKey: queryKeys.agents.detail(agent.urlKey) });
}, },
@@ -1513,60 +1574,339 @@ function PromptsTab({
}); });
useEffect(() => { useEffect(() => {
if (awaitingRefresh && agent !== lastAgentRef.current) { if (!bundle) return;
setAwaitingRefresh(false); const availablePaths = bundle.files.map((file) => file.path);
setDraft(null); if (availablePaths.length === 0) {
if (selectedFile !== bundle.entryFile) setSelectedFile(bundle.entryFile);
return;
} }
lastAgentRef.current = agent; if (!availablePaths.includes(selectedFile)) {
}, [agent, awaitingRefresh]); setSelectedFile(availablePaths.includes(bundle.entryFile) ? bundle.entryFile : availablePaths[0]!);
}
}, [bundle, selectedFile]);
const isSaving = updateAgent.isPending || awaitingRefresh; useEffect(() => {
const versionKey = selectedFileDetail ? `${selectedFileDetail.path}:${selectedFileDetail.content}` : `draft:${selectedOrEntryFile}`;
if (awaitingRefresh) {
setAwaitingRefresh(false);
setBundleDraft(null);
setDraft(null);
lastFileVersionRef.current = versionKey;
return;
}
if (lastFileVersionRef.current !== versionKey) {
setDraft(null);
lastFileVersionRef.current = versionKey;
}
}, [awaitingRefresh, selectedFileDetail, selectedOrEntryFile]);
useEffect(() => {
if (!bundle) return;
setBundleDraft((current) => {
if (current) return current;
return {
mode: bundle.mode ?? "managed",
rootPath: bundle.rootPath ?? "",
entryFile: bundle.entryFile,
};
});
}, [bundle]);
const currentContent = selectedFileExists ? (selectedFileDetail?.content ?? "") : "";
const displayValue = draft ?? currentContent;
const bundleDirty = Boolean(
bundleDraft &&
(
bundleDraft.mode !== (bundle?.mode ?? "managed") ||
bundleDraft.rootPath !== (bundle?.rootPath ?? "") ||
bundleDraft.entryFile !== (bundle?.entryFile ?? "AGENTS.md")
),
);
const fileDirty = draft !== null && draft !== currentContent;
const isDirty = bundleDirty || fileDirty;
const isSaving = updateBundle.isPending || saveFile.isPending || deleteFile.isPending || awaitingRefresh;
useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]); useEffect(() => { onSavingChange(isSaving); }, [onSavingChange, isSaving]);
useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]); useEffect(() => { onDirtyChange(isDirty); }, [onDirtyChange, isDirty]);
useEffect(() => { useEffect(() => {
onSaveActionChange(isDirty ? () => { onSaveActionChange(isDirty ? () => {
updateAgent.mutate({ adapterConfig: { promptTemplate: draft } }); const save = async () => {
const shouldClearLegacy =
Boolean(bundle?.legacyPromptTemplateActive) || Boolean(bundle?.legacyBootstrapPromptTemplateActive);
if (bundleDirty && bundleDraft) {
await updateBundle.mutateAsync({
mode: bundleDraft.mode,
rootPath: bundleDraft.mode === "external" ? bundleDraft.rootPath : null,
entryFile: bundleDraft.entryFile,
});
}
if (fileDirty) {
await saveFile.mutateAsync({
path: selectedOrEntryFile,
content: displayValue,
clearLegacyPromptTemplate: shouldClearLegacy,
});
}
};
void save().catch(() => undefined);
} : null); } : null);
}, [onSaveActionChange, isDirty, draft, updateAgent]); }, [
bundle,
bundleDirty,
bundleDraft,
displayValue,
fileDirty,
isDirty,
onSaveActionChange,
saveFile,
selectedOrEntryFile,
updateBundle,
]);
useEffect(() => { useEffect(() => {
onCancelActionChange(isDirty ? () => setDraft(null) : null); onCancelActionChange(isDirty ? () => {
}, [onCancelActionChange, isDirty]); setDraft(null);
if (bundle) {
setBundleDraft({
mode: bundle.mode ?? "managed",
rootPath: bundle.rootPath ?? "",
entryFile: bundle.entryFile,
});
}
} : null);
}, [bundle, isDirty, onCancelActionChange]);
if (!isLocal) { if (!isLocal) {
return ( return (
<div className="max-w-3xl"> <div className="max-w-3xl">
<p className="text-sm text-muted-foreground"> <p className="text-sm text-muted-foreground">
Prompt templates are only available for local adapters. Instructions bundles are only available for local adapters.
</p> </p>
</div> </div>
); );
} }
if (bundleLoading && !bundle) {
return <div className="max-w-5xl text-sm text-muted-foreground">Loading instructions bundle</div>;
}
return ( return (
<div className="max-w-3xl space-y-4"> <div className="max-w-6xl space-y-4">
<div> <div className="border border-border rounded-lg p-4 space-y-4">
<h3 className="text-sm font-medium mb-3">Prompt Template</h3> <div className="flex items-start justify-between gap-4">
<div className="border border-border rounded-lg p-4 space-y-3"> <div>
<p className="text-sm text-muted-foreground"> <h3 className="text-sm font-medium">Instructions Bundle</h3>
{help.promptTemplate} <p className="mt-1 text-sm text-muted-foreground">
</p> `AGENTS.md` is the entry file. Sibling files like `HEARTBEAT.md`, `SOUL.md`, `TOOLS.md`, and arbitrary custom files live in the same bundle.
<MarkdownEditor </p>
value={displayValue}
onChange={(v) => setDraft(v ?? "")}
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
contentClassName="min-h-[88px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = `agents/${agent.id}/prompt-template`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Prompt template is replayed on every heartbeat. Keep it compact and dynamic to avoid recurring token cost and cache churn.
</div> </div>
<div className="text-xs text-muted-foreground">
{bundle?.files.length ?? 0} files
</div>
</div>
{(bundle?.legacyPromptTemplateActive || bundle?.legacyBootstrapPromptTemplateActive) && (
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Legacy inline prompt fields are still active for this agent. The next bundle save will migrate behavior into file-backed instructions and clear those legacy fields.
</div>
)}
{(bundle?.warnings ?? []).map((warning) => (
<div key={warning} className="rounded-md border border-sky-500/25 bg-sky-500/10 px-3 py-2 text-xs text-sky-100">
{warning}
</div>
))}
<div className="grid gap-3 md:grid-cols-3">
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Mode</span>
<div className="flex gap-2">
<Button
type="button"
size="sm"
variant={currentMode === "managed" ? "default" : "outline"}
onClick={() => setBundleDraft((current) => ({
mode: "managed",
rootPath: current?.rootPath ?? bundle?.rootPath ?? "",
entryFile: current?.entryFile ?? bundle?.entryFile ?? "AGENTS.md",
}))}
>
Managed
</Button>
<Button
type="button"
size="sm"
variant={currentMode === "external" ? "default" : "outline"}
onClick={() => setBundleDraft((current) => ({
mode: "external",
rootPath: current?.rootPath ?? bundle?.rootPath ?? "",
entryFile: current?.entryFile ?? bundle?.entryFile ?? "AGENTS.md",
}))}
>
External
</Button>
</div>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Entry file</span>
<Input
value={currentEntryFile}
onChange={(event) => {
const nextEntryFile = event.target.value || "AGENTS.md";
if (selectedOrEntryFile === currentEntryFile) {
setSelectedFile(nextEntryFile);
}
setBundleDraft((current) => ({
mode: current?.mode ?? bundle?.mode ?? "managed",
rootPath: current?.rootPath ?? bundle?.rootPath ?? "",
entryFile: nextEntryFile,
}));
}}
className="font-mono text-sm"
/>
</label>
<label className="space-y-1">
<span className="text-xs font-medium text-muted-foreground">Root path</span>
<Input
value={currentRootPath}
onChange={(event) => setBundleDraft((current) => ({
mode: current?.mode ?? bundle?.mode ?? "managed",
rootPath: event.target.value,
entryFile: current?.entryFile ?? bundle?.entryFile ?? "AGENTS.md",
}))}
disabled={currentMode === "managed"}
className="font-mono text-sm"
placeholder={currentMode === "managed" ? "Managed by Paperclip" : "/absolute/path/to/agent/prompts"}
/>
</label>
</div>
</div>
<div className="grid gap-4 lg:grid-cols-[260px_minmax(0,1fr)]">
<div className="border border-border rounded-lg p-3 space-y-3">
<div className="flex items-center justify-between">
<h4 className="text-sm font-medium">Files</h4>
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
const candidate = newFilePath.trim();
if (!candidate) return;
setSelectedFile(candidate);
setDraft("");
setNewFilePath("");
}}
disabled={!newFilePath.trim()}
>
Add
</Button>
</div>
<div className="flex gap-2">
<Input
value={newFilePath}
onChange={(event) => setNewFilePath(event.target.value)}
placeholder="docs/TOOLS.md"
className="font-mono text-sm"
/>
</div>
<div className="flex flex-wrap gap-2">
{["HEARTBEAT.md", "SOUL.md", "TOOLS.md"].map((filePath) => (
<Button
key={filePath}
type="button"
size="sm"
variant="outline"
onClick={() => {
setSelectedFile(filePath);
if (!fileOptions.includes(filePath)) setDraft("");
}}
>
{filePath}
</Button>
))}
</div>
<div className="space-y-1">
{[...new Set([currentEntryFile, ...fileOptions])].map((filePath) => {
const file = bundle?.files.find((entry) => entry.path === filePath);
return (
<button
key={filePath}
type="button"
className={cn(
"flex w-full items-center justify-between rounded-md border px-3 py-2 text-left text-sm",
filePath === selectedOrEntryFile ? "border-foreground/30 bg-accent/30" : "border-border",
)}
onClick={() => {
setSelectedFile(filePath);
if (!fileOptions.includes(filePath)) setDraft("");
}}
>
<span className="truncate font-mono">{filePath}</span>
<span className="ml-3 shrink-0 text-[11px] text-muted-foreground">
{file?.isEntryFile ? "entry" : file ? `${file.size}b` : "new"}
</span>
</button>
);
})}
</div>
</div>
<div className="border border-border rounded-lg p-4 space-y-3">
<div className="flex items-center justify-between gap-3">
<div>
<h4 className="text-sm font-medium font-mono">{selectedOrEntryFile}</h4>
<p className="text-xs text-muted-foreground">
{selectedFileExists
? `${selectedFileDetail?.language ?? "text"} file`
: "New file in this bundle"}
</p>
</div>
{selectedFileExists && selectedOrEntryFile !== currentEntryFile && (
<Button
type="button"
size="sm"
variant="outline"
onClick={() => {
if (confirm(`Delete ${selectedOrEntryFile}?`)) {
deleteFile.mutate(selectedOrEntryFile, {
onSuccess: () => {
setSelectedFile(currentEntryFile);
setDraft(null);
},
});
}
}}
disabled={deleteFile.isPending}
>
Delete
</Button>
)}
</div>
{selectedFileExists && fileLoading && !selectedFileDetail ? (
<p className="text-sm text-muted-foreground">Loading file</p>
) : isMarkdown(selectedOrEntryFile) ? (
<MarkdownEditor
value={displayValue}
onChange={(value) => setDraft(value ?? "")}
placeholder="# Agent instructions"
contentClassName="min-h-[420px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = `agents/${agent.id}/instructions/${selectedOrEntryFile.replaceAll("/", "-")}`;
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
) : (
<textarea
value={displayValue}
onChange={(event) => setDraft(event.target.value)}
className="min-h-[420px] w-full rounded-md border border-border bg-transparent px-3 py-2 font-mono text-sm outline-none"
placeholder="File contents"
/>
)}
</div> </div>
</div> </div>
</div> </div>