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

@@ -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 mockIssueApprovalService = 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(() => ({
listRuntimeSkillEntries: vi.fn(),
@@ -27,6 +37,7 @@ const mockCompanySkillService = vi.hoisted(() => ({
const mockSecretService = vi.hoisted(() => ({
resolveAdapterConfigForRuntime: vi.fn(),
normalizeAdapterConfigForPersistence: vi.fn(async (_companyId: string, config: Record<string, unknown>) => config),
}));
const mockLogActivity = vi.hoisted(() => vi.fn());
@@ -38,6 +49,7 @@ const mockAdapter = vi.hoisted(() => ({
vi.mock("../services/index.js", () => ({
agentService: () => mockAgentService,
agentInstructionsService: () => mockAgentInstructionsService,
accessService: () => mockAccessService,
approvalService: () => mockApprovalService,
companySkillService: () => mockCompanySkillService,
@@ -47,6 +59,7 @@ vi.mock("../services/index.js", () => ({
issueService: () => ({}),
logActivity: mockLogActivity,
secretService: () => mockSecretService,
syncInstructionsBundleConfigFromFilePath: vi.fn((_agent, config) => config),
workspaceOperationService: () => mockWorkspaceOperationService,
}));

View File

@@ -36,6 +36,11 @@ const companySkillSvc = {
importPackageFiles: vi.fn(),
};
const agentInstructionsSvc = {
exportFiles: vi.fn(),
materializeManagedBundle: vi.fn(),
};
vi.mock("../services/companies.js", () => ({
companyService: () => companySvc,
}));
@@ -60,6 +65,10 @@ vi.mock("../services/company-skills.js", () => ({
companySkillService: () => companySkillSvc,
}));
vi.mock("../services/agent-instructions.js", () => ({
agentInstructionsService: () => agentInstructionsSvc,
}));
const { companyPortabilityService } = await import("../services/company-portability.js");
describe("company portability", () => {
@@ -231,6 +240,21 @@ describe("company portability", () => {
};
});
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 () => {
@@ -536,14 +560,24 @@ describe("company portability", () => {
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterType: "codex_local",
adapterConfig: expect.objectContaining({
promptTemplate: "You are ClaudeCoder.",
dangerouslyBypassApprovalsAndSandbox: true,
}),
}));
expect(agentSvc.create).toHaveBeenCalledWith("company-imported", expect.objectContaining({
adapterConfig: expect.not.objectContaining({
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,
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) ?? {};

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 { accessService } from "./access.js";
import { agentService } from "./agents.js";
import { agentInstructionsService } from "./agent-instructions.js";
import { generateReadme } from "./company-export-readme.js";
import { companySkillService } from "./company-skills.js";
import { companyService } from "./companies.js";
@@ -380,7 +381,11 @@ function normalizePortableConfig(
if (
key === "cwd" ||
key === "instructionsFilePath" ||
key === "instructionsBundleMode" ||
key === "instructionsRootPath" ||
key === "instructionsEntryFile" ||
key === "promptTemplate" ||
key === "bootstrapPromptTemplate" ||
key === "paperclipSkillSync"
) 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}`;
}
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) {
const companies = companyService(db);
const agents = agentService(db);
const instructions = agentInstructionsService();
const access = accessService(db);
const projects = projectService(db);
const issues = issueService(db);
@@ -1783,9 +1744,8 @@ export function companyPortabilityService(db: Db) {
if (include.agents) {
for (const agent of agentRows) {
const slug = idToSlug.get(agent.id)!;
const instructions = await readAgentInstructions(agent);
if (instructions.warning) warnings.push(instructions.warning);
const agentPath = `agents/${slug}/AGENTS.md`;
const exportedInstructions = await instructions.exportFiles(agent);
warnings.push(...exportedInstructions.warnings);
const envInputsStart = envInputs.length;
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.`);
delete portableAdapterConfig.command;
}
files[agentPath] = buildMarkdown(
stripEmptyValues({
name: agent.name,
title: agent.title ?? null,
reportsTo: reportsToSlug,
skills: desiredSkills.length > 0 ? desiredSkills : undefined,
}) as Record<string, unknown>,
instructions.body,
);
for (const [relativePath, content] of Object.entries(exportedInstructions.files)) {
const targetPath = `agents/${slug}/${relativePath}`;
if (relativePath === exportedInstructions.entryFile) {
files[targetPath] = buildMarkdown(
stripEmptyValues({
name: agent.name,
title: agent.title ?? null,
reportsTo: reportsToSlug,
skills: desiredSkills.length > 0 ? desiredSkills : undefined,
}) as Record<string, unknown>,
content,
);
} else {
files[targetPath] = content;
}
}
const extension = stripEmptyValues({
role: agent.role !== "agent" ? agent.role : undefined,
@@ -2346,26 +2312,39 @@ export function companyPortabilityService(db: Db) {
continue;
}
const markdownRaw = plan.source.files[manifestAgent.path];
if (!markdownRaw) {
warnings.push(`Missing AGENTS markdown for ${manifestAgent.slug}; imported without prompt template.`);
const bundlePrefix = `agents/${manifestAgent.slug}/`;
const bundleFiles = Object.fromEntries(
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
const adapterOverride = input.adapterOverrides?.[planAgent.slug];
const effectiveAdapterType = adapterOverride?.adapterType ?? manifestAgent.adapterType;
const baseAdapterConfig = adapterOverride?.adapterConfig
? { ...adapterOverride.adapterConfig, promptTemplate }
: { ...manifestAgent.adapterConfig, promptTemplate } as Record<string, unknown>;
? { ...adapterOverride.adapterConfig }
: { ...manifestAgent.adapterConfig } as Record<string, unknown>;
const desiredSkills = manifestAgent.skills ?? [];
const adapterConfigWithSkills = writePaperclipSkillSyncPreference(
baseAdapterConfig,
desiredSkills,
);
delete adapterConfigWithSkills.promptTemplate;
delete adapterConfigWithSkills.bootstrapPromptTemplate;
delete adapterConfigWithSkills.instructionsFilePath;
delete adapterConfigWithSkills.instructionsBundleMode;
delete adapterConfigWithSkills.instructionsRootPath;
delete adapterConfigWithSkills.instructionsEntryFile;
const patch = {
name: planAgent.plannedName,
role: manifestAgent.role,
@@ -2382,7 +2361,7 @@ export function companyPortabilityService(db: Db) {
};
if (planAgent.action === "update" && planAgent.existingAgentId) {
const updated = await agents.update(planAgent.existingAgentId, patch);
let updated = await agents.update(planAgent.existingAgentId, patch);
if (!updated) {
warnings.push(`Skipped update for missing agent ${planAgent.existingAgentId}.`);
resultAgents.push({
@@ -2394,6 +2373,15 @@ export function companyPortabilityService(db: Db) {
});
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);
existingSlugToAgentId.set(normalizeAgentUrlKey(updated.name) ?? updated.id, updated.id);
resultAgents.push({
@@ -2406,7 +2394,16 @@ export function companyPortabilityService(db: Db) {
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);
existingSlugToAgentId.set(normalizeAgentUrlKey(created.name) ?? created.id, created.id);
resultAgents.push({

View File

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