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:
185
server/src/__tests__/agent-instructions-routes.test.ts
Normal file
185
server/src/__tests__/agent-instructions-routes.test.ts
Normal 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),
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
}));
|
||||
|
||||
|
||||
@@ -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,
|
||||
}),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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) ?? {};
|
||||
|
||||
531
server/src/services/agent-instructions.ts
Normal file
531
server/src/services/agent-instructions.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
@@ -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({
|
||||
|
||||
@@ -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";
|
||||
|
||||
Reference in New Issue
Block a user