Default new agents to managed AGENTS.md

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
dotta
2026-03-19 07:57:25 -05:00
parent 16e221d03c
commit 2f076f2add
2 changed files with 130 additions and 5 deletions

View File

@@ -16,7 +16,9 @@ const mockAccessService = vi.hoisted(() => ({
hasPermission: vi.fn(),
}));
const mockApprovalService = vi.hoisted(() => ({}));
const mockApprovalService = vi.hoisted(() => ({
create: vi.fn(),
}));
const mockBudgetService = vi.hoisted(() => ({}));
const mockHeartbeatService = vi.hoisted(() => ({}));
const mockIssueApprovalService = vi.hoisted(() => ({
@@ -176,13 +178,26 @@ describe("agent skill routes", () => {
budgetMonthlyCents: Number(input.budgetMonthlyCents ?? 0),
permissions: null,
}));
mockApprovalService.create = vi.fn(async (_companyId: string, input: Record<string, unknown>) => ({
mockApprovalService.create.mockImplementation(async (_companyId: string, input: Record<string, unknown>) => ({
id: "approval-1",
companyId: "company-1",
type: "hire_agent",
status: "pending",
payload: input.payload ?? {},
}));
mockAgentInstructionsService.materializeManagedBundle.mockImplementation(
async (agent: Record<string, unknown>, files: Record<string, string>) => ({
bundle: null,
adapterConfig: {
...((agent.adapterConfig as Record<string, unknown> | undefined) ?? {}),
instructionsBundleMode: "managed",
instructionsRootPath: `/tmp/${String(agent.id)}/instructions`,
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: `/tmp/${String(agent.id)}/instructions/AGENTS.md`,
promptTemplate: files["AGENTS.md"] ?? "",
},
}),
);
mockLogActivity.mockResolvedValue(undefined);
mockAccessService.canUser.mockResolvedValue(true);
mockAccessService.hasPermission.mockResolvedValue(true);
@@ -289,6 +304,44 @@ describe("agent skill routes", () => {
);
});
it("materializes a managed AGENTS.md for directly created local agents", async () => {
const res = await request(createApp())
.post("/api/companies/company-1/agents")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are QA.",
},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockAgentInstructionsService.materializeManagedBundle).toHaveBeenCalledWith(
expect.objectContaining({
id: "11111111-1111-4111-8111-111111111111",
adapterType: "claude_local",
}),
{ "AGENTS.md": "You are QA." },
{ entryFile: "AGENTS.md", replaceExisting: false },
);
expect(mockAgentService.update).toHaveBeenCalledWith(
"11111111-1111-4111-8111-111111111111",
expect.objectContaining({
adapterConfig: expect.objectContaining({
instructionsBundleMode: "managed",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md",
}),
}),
);
expect(mockAgentService.update.mock.calls.at(-1)?.[1]).not.toMatchObject({
adapterConfig: expect.objectContaining({
promptTemplate: expect.anything(),
}),
});
});
it("includes canonical desired skills in hire approvals", async () => {
const db = createDb(true);
@@ -316,4 +369,35 @@ describe("agent skill routes", () => {
}),
);
});
it("uses managed AGENTS config in hire approval payloads", async () => {
const res = await request(createApp(createDb(true)))
.post("/api/companies/company-1/agent-hires")
.send({
name: "QA Agent",
role: "engineer",
adapterType: "claude_local",
adapterConfig: {
promptTemplate: "You are QA.",
},
});
expect(res.status, JSON.stringify(res.body)).toBe(201);
expect(mockApprovalService.create).toHaveBeenCalledWith(
"company-1",
expect.objectContaining({
payload: expect.objectContaining({
adapterConfig: expect.objectContaining({
instructionsBundleMode: "managed",
instructionsEntryFile: "AGENTS.md",
instructionsFilePath: "/tmp/11111111-1111-4111-8111-111111111111/instructions/AGENTS.md",
}),
}),
}),
);
const approvalInput = mockApprovalService.create.mock.calls.at(-1)?.[1] as
| { payload?: { adapterConfig?: Record<string, unknown> } }
| undefined;
expect(approvalInput?.payload?.adapterConfig?.promptTemplate).toBeUndefined();
});
});

View File

@@ -63,7 +63,9 @@ export function agentRoutes(db: Db) {
gemini_local: "instructionsFilePath",
opencode_local: "instructionsFilePath",
cursor: "instructionsFilePath",
pi_local: "instructionsFilePath",
};
const DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES = new Set(Object.keys(DEFAULT_INSTRUCTIONS_PATH_KEYS));
const KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]);
const router = Router();
@@ -328,6 +330,43 @@ export function agentRoutes(db: Db) {
return path.resolve(cwd, trimmed);
}
async function materializeDefaultInstructionsBundleForNewAgent<T extends {
id: string;
companyId: string;
name: string;
adapterType: string;
adapterConfig: unknown;
}>(agent: T): Promise<T> {
if (!DEFAULT_MANAGED_INSTRUCTIONS_ADAPTER_TYPES.has(agent.adapterType)) {
return agent;
}
const adapterConfig = asRecord(agent.adapterConfig) ?? {};
const hasExplicitInstructionsBundle =
Boolean(asNonEmptyString(adapterConfig.instructionsBundleMode))
|| Boolean(asNonEmptyString(adapterConfig.instructionsRootPath))
|| Boolean(asNonEmptyString(adapterConfig.instructionsEntryFile))
|| Boolean(asNonEmptyString(adapterConfig.instructionsFilePath))
|| Boolean(asNonEmptyString(adapterConfig.agentsMdPath));
if (hasExplicitInstructionsBundle) {
return agent;
}
const promptTemplate = typeof adapterConfig.promptTemplate === "string"
? adapterConfig.promptTemplate
: "";
const materialized = await instructions.materializeManagedBundle(
agent,
{ "AGENTS.md": promptTemplate },
{ entryFile: "AGENTS.md", replaceExisting: false },
);
const nextAdapterConfig = { ...materialized.adapterConfig };
delete nextAdapterConfig.promptTemplate;
const updated = await svc.update(agent.id, { adapterConfig: nextAdapterConfig });
return (updated as T | null) ?? { ...agent, adapterConfig: nextAdapterConfig };
}
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
assertCompanyAccess(req, targetAgent.companyId);
if (req.actor.type === "board") return;
@@ -1035,12 +1074,13 @@ export function agentRoutes(db: Db) {
const requiresApproval = company.requireBoardApprovalForNewAgents;
const status = requiresApproval ? "pending_approval" : "idle";
const agent = await svc.create(companyId, {
const createdAgent = await svc.create(companyId, {
...normalizedHireInput,
status,
spentMonthlyCents: 0,
lastHeartbeatAt: null,
});
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent);
let approval: Awaited<ReturnType<typeof approvalsSvc.getById>> | null = null;
const actor = getActorInfo(req);
@@ -1049,7 +1089,7 @@ export function agentRoutes(db: Db) {
const requestedAdapterType = normalizedHireInput.adapterType ?? agent.adapterType;
const requestedAdapterConfig =
redactEventPayload(
(normalizedHireInput.adapterConfig ?? agent.adapterConfig) as Record<string, unknown>,
(agent.adapterConfig ?? normalizedHireInput.adapterConfig) as Record<string, unknown>,
) ?? {};
const requestedRuntimeConfig =
redactEventPayload(
@@ -1172,13 +1212,14 @@ export function agentRoutes(db: Db) {
normalizedAdapterConfig,
);
const agent = await svc.create(companyId, {
const createdAgent = await svc.create(companyId, {
...createInput,
adapterConfig: normalizedAdapterConfig,
status: "idle",
spentMonthlyCents: 0,
lastHeartbeatAt: null,
});
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent);
const actor = getActorInfo(req);
await logActivity(db, {