Reject prompt templates for new bundled agents

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
DottaandPaperclip committed 2026-04-28 09:36:12 -05:00
1 parent b779e43272
commit 484d37feb9
16 files changed
+85 -87

No files matched your search

@@ -66,8 +66,6 @@ export function buildClaudeLocalConfig(v: CreateConfigValues): Record<string, un
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.effort = v.thinkingEffort;
if (v.chrome) ac.chrome = true;
@@ -70,8 +70,6 @@ export function buildCodexLocalConfig(v: CreateConfigValues): Record<string, unk
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_CODEX_LOCAL_MODEL;
if (v.thinkingEffort) ac.modelReasoningEffort = v.thinkingEffort;
ac.timeoutSec = 0;
@@ -61,8 +61,6 @@ export function buildCursorLocalConfig(v: CreateConfigValues): Record<string, un
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_CURSOR_LOCAL_MODEL;
const mode = normalizeMode(v.thinkingEffort);
if (mode) ac.mode = mode;
@@ -55,8 +55,6 @@ export function buildGeminiLocalConfig(v: CreateConfigValues): Record<string, un
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
ac.model = v.model || DEFAULT_GEMINI_LOCAL_MODEL;
ac.timeoutSec = 0;
ac.graceSec = 15;
@@ -54,8 +54,6 @@ export function buildOpenCodeLocalConfig(v: CreateConfigValues): Record<string,
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.variant = v.thinkingEffort;
ac.dangerouslySkipPermissions = v.dangerouslySkipPermissions;
@@ -47,8 +47,6 @@ export function buildPiLocalConfig(v: CreateConfigValues): Record<string, unknow
const ac: Record<string, unknown> = {};
if (v.cwd) ac.cwd = v.cwd;
if (v.instructionsFilePath) ac.instructionsFilePath = v.instructionsFilePath;
if (v.promptTemplate) ac.promptTemplate = v.promptTemplate;
if (v.bootstrapPrompt) ac.bootstrapPromptTemplate = v.bootstrapPrompt;
if (v.model) ac.model = v.model;
if (v.thinkingEffort) ac.thinking = v.thinkingEffort;
+14
View File
@@ -26,6 +26,20 @@ describe("dynamic adapter type validation schemas", () => {
).toThrow();
});
it("accepts an explicit managed instructions bundle for new agents", () => {
expect(
createAgentSchema.parse({
name: "Bundle Agent",
adapterType: "codex_local",
instructionsBundle: {
files: {
"AGENTS.md": "Use AGENTS.md.",
},
},
}).instructionsBundle?.files["AGENTS.md"],
).toBe("Use AGENTS.md.");
});
it("accepts external adapter types in invite acceptance schema", () => {
expect(
acceptInviteSchema.parse({
+8
View File
@@ -44,6 +44,13 @@ const adapterConfigSchema = z.record(z.unknown()).superRefine((value, ctx) => {
}
});
export const createAgentInstructionsBundleSchema = z.object({
entryFile: z.string().trim().min(1).optional(),
files: z.record(z.string()).refine((files) => Object.keys(files).length > 0, {
message: "instructionsBundle.files must contain at least one file",
}),
});
export const createAgentSchema = z.object({
name: z.string().min(1),
role: z.enum(AGENT_ROLES).optional().default("general"),
@@ -54,6 +61,7 @@ export const createAgentSchema = z.object({
desiredSkills: z.array(z.string().min(1)).optional(),
adapterType: agentAdapterTypeSchema,
adapterConfig: adapterConfigSchema.optional().default({}),
instructionsBundle: createAgentInstructionsBundleSchema.optional(),
runtimeConfig: z.record(z.unknown()).optional().default({}),
defaultEnvironmentId: z.string().uuid().optional().nullable(),
budgetMonthlyCents: z.number().int().nonnegative().optional().default(0),
@@ -481,8 +481,11 @@ describe.sequential("agent skill routes", () => {
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are QA.",
adapterConfig: {},
instructionsBundle: {
files: {
"AGENTS.md": "You are QA.",
},
},
}));
@@ -504,7 +507,7 @@ describe.sequential("agent skill routes", () => {
});
});
it("clears legacy prompt templates when directly created local agents already provide instructions", async () => {
it("rejects legacy prompt templates for directly created local agents", async () => {
const res = await requestApp(await createApp(), (baseUrl) => request(baseUrl)
.post("/api/companies/company-1/agents")
.send({
@@ -518,21 +521,10 @@ describe.sequential("agent skill routes", () => {
},
}));
expect([200, 201], JSON.stringify(res.body)).toContain(res.status);
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.error).toContain("New agents must use instructionsBundle/AGENTS.md");
expect(mockAgentService.create).not.toHaveBeenCalled();
expect(mockAgentInstructionsService.materializeManagedBundle).not.toHaveBeenCalled();
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterConfig: expect.objectContaining({
instructionsFilePath: "/tmp/existing/AGENTS.md",
}),
}),
);
const updateInput = mockAgentService.update.mock.calls.at(-1)?.[1] as
| { adapterConfig?: Record<string, unknown> }
| undefined;
expect(updateInput?.adapterConfig?.promptTemplate).toBeUndefined();
expect(updateInput?.adapterConfig?.bootstrapPromptTemplate).toBeUndefined();
});
it("materializes the bundled CEO instruction set for default CEO agents", async () => {
@@ -683,8 +675,11 @@ describe.sequential("agent skill routes", () => {
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are QA.",
adapterConfig: {},
instructionsBundle: {
files: {
"AGENTS.md": "You are QA.",
},
},
});
@@ -707,7 +702,7 @@ describe.sequential("agent skill routes", () => {
expect(approvalInput?.payload?.adapterConfig?.promptTemplate).toBeUndefined();
});
it("clears legacy prompt templates from hire approval payloads that already provide instructions", async () => {
it("rejects legacy prompt templates for hire approval payloads", async () => {
const res = await request(await createApp(createDb(true)))
.post("/api/companies/company-1/agent-hires")
.send({
@@ -721,22 +716,9 @@ describe.sequential("agent skill routes", () => {
},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(res.status, JSON.stringify(res.body)).toBe(422);
expect(res.body.error).toContain("New agents must use instructionsBundle/AGENTS.md");
expect(mockAgentService.create).not.toHaveBeenCalled();
expect(mockAgentInstructionsService.materializeManagedBundle).not.toHaveBeenCalled();
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
payload: expect.objectContaining({
adapterConfig: expect.objectContaining({
instructionsFilePath: "/tmp/existing/AGENTS.md",
}),
}),
}),
);
const approvalInput = mockApprovalService.create.mock.calls.at(-1)?.[1] as
| { payload?: { adapterConfig?: Record<string, unknown> } }
| undefined;
expect(approvalInput?.payload?.adapterConfig?.promptTemplate).toBeUndefined();
expect(approvalInput?.payload?.adapterConfig?.bootstrapPromptTemplate).toBeUndefined();
});
});
+32 -10
View File
@@ -690,7 +690,10 @@ export function agentRoutes(
role: string;
adapterType: string;
adapterConfig: unknown;
}>(agent: T): Promise<T> {
}>(
agent: T,
input?: { files: Record<string, string>; entryFile?: string },
): Promise<T> {
if (!adapterSupportsInstructionsBundle(agent.adapterType)) {
return agent;
}
@@ -715,24 +718,33 @@ export function agentRoutes(
return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig };
}
const promptTemplate = typeof adapterConfig.promptTemplate === "string"
? adapterConfig.promptTemplate
: "";
const files = promptTemplate.trim().length === 0
? await loadDefaultAgentInstructionsBundle(resolveDefaultAgentInstructionsBundleRole(agent.role))
: { "AGENTS.md": promptTemplate };
const files = input?.files
?? await loadDefaultAgentInstructionsBundle(resolveDefaultAgentInstructionsBundleRole(agent.role));
const materialized = await instructions.materializeManagedBundle(
agent,
files,
{ entryFile: "AGENTS.md", replaceExisting: false },
{ entryFile: input?.entryFile ?? "AGENTS.md", replaceExisting: false },
);
const nextAdapterConfig = { ...materialized.adapterConfig };
delete nextAdapterConfig.promptTemplate;
delete nextAdapterConfig.bootstrapPromptTemplate;
const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig });
return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig };
}
function assertNoNewAgentLegacyPromptTemplate(adapterType: string, adapterConfig: Record<string, unknown>) {
if (!adapterSupportsInstructionsBundle(adapterType)) return;
if (
Object.prototype.hasOwnProperty.call(adapterConfig, "promptTemplate")
|| Object.prototype.hasOwnProperty.call(adapterConfig, "bootstrapPromptTemplate")
) {
throw unprocessable(
"New agents must use instructionsBundle/AGENTS.md instead of adapterConfig.promptTemplate or bootstrapPromptTemplate",
);
}
}
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type !== "board") {
@@ -1474,11 +1486,16 @@ export function agentRoutes(
const sourceIssueIds = parseSourceIssueIds(req.body);
const {
desiredSkills: requestedDesiredSkills,
instructionsBundle,
sourceIssueId: _sourceIssueId,
sourceIssueIds: _sourceIssueIds,
...hireInput
} = req.body;
hireInput.adapterType = assertKnownAdapterType(hireInput.adapterType);
assertNoNewAgentLegacyPromptTemplate(
hireInput.adapterType,
(hireInput.adapterConfig ?? {}) as Record<string, unknown>,
);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectAgentAdapterWorkspaceCommandPaths(hireInput.adapterConfig),
@@ -1531,7 +1548,7 @@ export function agentRoutes(
spentMonthlyCents: 0,
lastHeartbeatAt: null,
});
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent);
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
let approval: Awaited<ReturnType<typeof approvalsSvc.getById>> | null = null;
const actor = getActorInfo(req);
@@ -1661,9 +1678,14 @@ export function agentRoutes(
const {
desiredSkills: requestedDesiredSkills,
instructionsBundle,
...createInput
} = req.body;
createInput.adapterType = assertKnownAdapterType(createInput.adapterType);
assertNoNewAgentLegacyPromptTemplate(
createInput.adapterType,
(createInput.adapterConfig ?? {}) as Record<string, unknown>,
);
assertNoAgentHostWorkspaceCommandMutation(
req,
collectAgentAdapterWorkspaceCommandPaths(createInput.adapterConfig),
@@ -1706,7 +1728,7 @@ export function agentRoutes(
spentMonthlyCents: 0,
lastHeartbeatAt: null,
});
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent);
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent, instructionsBundle);
const actor = getActorInfo(req);
await logActivity(db, {
+2 -1
View File
@@ -85,7 +85,7 @@ curl -sS "$PAPERCLIP_API_URL/llms/agent-icons.txt" \
- capabilities
- managed instructions bundle (`AGENTS.md`) for adapters that support it; avoid durable `promptTemplate` config
- for coding or execution agents, include the Paperclip execution contract: start actionable work in the same heartbeat; do not stop at a plan unless planning was requested; leave durable progress with a clear next action; use child issues for long or parallel delegated work instead of polling; mark blocked work with owner/action; respect budget, pause/cancel, approval gates, and company boundaries
- instruction text such as `AGENTS.md` built from step 4; for local managed-bundle adapters, agent-authenticated hire requests may send the adapted `AGENTS.md` in `adapterConfig.promptTemplate` only as a creation-time transport because the server materializes it into managed `AGENTS.md` and removes the legacy field. Do not set `promptTemplate` when you are already setting an instructions bundle/path.
- instruction text such as `AGENTS.md` built from step 4; for local managed-bundle adapters, send this as top-level `instructionsBundle.files["AGENTS.md"]`. Do not set `adapterConfig.promptTemplate` or `bootstrapPromptTemplate` for new agents.
- source issue linkage (`sourceIssueId` or `sourceIssueIds`) when this hire came from an issue
### 7. Review the draft against the quality checklist
@@ -109,6 +109,7 @@ curl -sS -X POST "$PAPERCLIP_API_URL/api/companies/$PAPERCLIP_COMPANY_ID/agent-h
"desiredSkills": ["vercel-labs/agent-browser/agent-browser"],
"adapterType": "codex_local",
"adapterConfig": {"cwd": "/abs/path/to/repo", "model": "o4-mini"},
"instructionsBundle": {"files": {"AGENTS.md": "You are the CTO..."}},
"runtimeConfig": {"heartbeat": {"enabled": false, "wakeOnDemand": true}},
"sourceIssueId": "<issue-id>"
}'
@@ -41,7 +41,7 @@ If you are hiring a role that is not in this index, do not force a fit. Use the
## How to apply an exact template
1. Open the matching reference in `references/agents/`.
2. Copy that template into the new agent's instruction bundle (usually `AGENTS.md`). For agent-authenticated hire requests using local managed-bundle adapters, `adapterConfig.promptTemplate` is only a creation-time transport for the adapted template; Paperclip materializes it into managed `AGENTS.md` and removes the legacy field. Do not combine `promptTemplate` with an explicit instructions bundle/path.
2. Copy that template into the new agent's instruction bundle (usually `AGENTS.md`). For hire requests using local managed-bundle adapters, send the adapted template as top-level `instructionsBundle.files["AGENTS.md"]`. Do not put new-agent instructions in `adapterConfig.promptTemplate`.
3. Replace placeholders like `{{companyName}}`, `{{managerTitle}}`, `{{issuePrefix}}`, and URLs.
4. Remove tools or workflows the target adapter cannot use.
5. Keep the Paperclip heartbeat requirement and the task-comment requirement.
@@ -42,8 +42,13 @@ Request body matches agent create shape:
"adapterType": "claude_local",
"adapterConfig": {
"cwd": "/absolute/path",
"model": "claude-sonnet-4-5-20250929",
"promptTemplate": "You are CTO..."
"model": "claude-sonnet-4-5-20250929"
},
"instructionsBundle": {
"entryFile": "AGENTS.md",
"files": {
"AGENTS.md": "You are CTO..."
}
},
"runtimeConfig": {
"heartbeat": {
@@ -118,7 +118,7 @@ How the agent verifies its own work before marking an issue done or handing it t
- **Over-generic prompts.** "Be helpful, be thorough, be correct" is worthless — the next agent drafts a better version by reading the template you adapted from. Write role-specific guidance only.
- **Lens dumping.** Copying every lens from an expert template into an unrelated role adds noise and burns context. Five well-chosen lenses beat fifteen irrelevant ones.
- **Permission sprawl.** Do not grant write access, admin endpoints, or broad skill sets "just in case." Grant exactly what the role needs.
- **Secrets in adapter config.** Do not embed long-lived tokens, API keys, or private URLs in `adapterConfig` or `promptTemplate` when environment injection or a scoped skill can carry the capability instead.
- **Secrets in agent config.** Do not embed long-lived tokens, API keys, or private URLs in `adapterConfig`, `instructionsBundle`, or legacy prompt fields when environment injection or a scoped skill can carry the capability instead.
- **Silent timer heartbeats.** A timer heartbeat burns budget every interval. If the role has no scheduled work, leave it off.
- **Bypassing governance.** Never skip `sourceIssueId`, reporting line, icon, or approval flow to ship faster. Hires without these are hard to audit and hard to hand off.
- **Copying another company's prompt verbatim.** Placeholders like `{{companyName}}`, `{{managerTitle}}`, and `{{issuePrefix}}` must be replaced with this company's values before submitting the hire.
@@ -57,13 +57,13 @@ Use it for every path: exact template, adjacent template, or generic fallback.
- [ ] `sourceIssueId` (or `sourceIssueIds`) is set when the hire was triggered by an issue
- [ ] `desiredSkills` lists only skills that already exist in the company library, or will be installed first via the company-skills workflow
- [ ] Adapter config matches this Paperclip instance (cwd, model, credentials) per `/llms/agent-configuration/<adapter>.txt`
- [ ] Local managed-bundle adapters end up with managed `AGENTS.md` config, not durable `adapterConfig.promptTemplate`; use `promptTemplate` only as the creation-time transport when the API caller cannot set bundle files directly
- [ ] Local managed-bundle adapters send custom instructions through top-level `instructionsBundle.files["AGENTS.md"]` and do not set `adapterConfig.promptTemplate` or `bootstrapPromptTemplate`
- [ ] Placeholders like `{{companyName}}`, `{{managerTitle}}`, `{{issuePrefix}}`, and any URL stubs are replaced with real values
## H. Safety and permissions (least privilege)
- [ ] The hire grants only the access the role needs — no "just in case" permissions
- [ ] No secrets are embedded in plain text in `adapterConfig`, creation-time instruction text, or any legacy prompt field; prefer environment-injected credentials or scoped skills
- [ ] No secrets are embedded in plain text in `adapterConfig`, `instructionsBundle`, or any legacy prompt field; prefer environment-injected credentials or scoped skills
- [ ] Any `desiredSkills` or adapter settings that expand external-system access, browser/network reach, filesystem scope, or secret-handling capability are individually justified in the hire comment
- [ ] `runtimeConfig.heartbeat.enabled` is `false` unless the role genuinely needs scheduled recurring work AND `intervalSec` is justified in the hire comment
- [ ] `AGENTS.md` explicitly names anything the role must never do (external posts, shared infra changes, destructive ops without approval)
-22
View File
@@ -724,28 +724,6 @@ export function AgentConfigForm(props: AgentConfigFormProps) {
</Field>
)}
{/* Prompt template (create mode only — edit mode shows this in Identity) */}
{isLocal && isCreate && (
<>
<Field label="Prompt Template" hint={help.promptTemplate}>
<MarkdownEditor
value={val!.promptTemplate}
onChange={(v) => set!({ promptTemplate: v })}
placeholder="You are agent {{ agent.name }}. Your role is {{ agent.role }}..."
contentClassName="min-h-[88px] text-sm font-mono"
imageUploadHandler={async (file) => {
const namespace = "agents/drafts/prompt-template";
const asset = await uploadMarkdownImage.mutateAsync({ file, namespace });
return asset.contentPath;
}}
/>
</Field>
<div className="rounded-md border border-amber-500/25 bg-amber-500/10 px-3 py-2 text-xs text-amber-100">
Prompt template is replayed on every heartbeat. Prefer small task framing and variables like <code>{"{{ context.* }}"}</code> or <code>{"{{ run.* }}"}</code>; avoid repeating stable instructions here.
</div>
</>
)}
{/* Adapter-specific fields are rendered inside Permissions & Configuration */}
</div>