Default new agents to managed AGENTS.md
Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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, {
|
||||
|
||||
Reference in New Issue
Block a user