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(),
|
hasPermission: vi.fn(),
|
||||||
}));
|
}));
|
||||||
|
|
||||||
const mockApprovalService = vi.hoisted(() => ({}));
|
const mockApprovalService = vi.hoisted(() => ({
|
||||||
|
create: vi.fn(),
|
||||||
|
}));
|
||||||
const mockBudgetService = vi.hoisted(() => ({}));
|
const mockBudgetService = vi.hoisted(() => ({}));
|
||||||
const mockHeartbeatService = vi.hoisted(() => ({}));
|
const mockHeartbeatService = vi.hoisted(() => ({}));
|
||||||
const mockIssueApprovalService = vi.hoisted(() => ({
|
const mockIssueApprovalService = vi.hoisted(() => ({
|
||||||
@@ -176,13 +178,26 @@ describe("agent skill routes", () => {
|
|||||||
budgetMonthlyCents: Number(input.budgetMonthlyCents ?? 0),
|
budgetMonthlyCents: Number(input.budgetMonthlyCents ?? 0),
|
||||||
permissions: null,
|
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",
|
id: "approval-1",
|
||||||
companyId: "company-1",
|
companyId: "company-1",
|
||||||
type: "hire_agent",
|
type: "hire_agent",
|
||||||
status: "pending",
|
status: "pending",
|
||||||
payload: input.payload ?? {},
|
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);
|
mockLogActivity.mockResolvedValue(undefined);
|
||||||
mockAccessService.canUser.mockResolvedValue(true);
|
mockAccessService.canUser.mockResolvedValue(true);
|
||||||
mockAccessService.hasPermission.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 () => {
|
it("includes canonical desired skills in hire approvals", async () => {
|
||||||
const db = createDb(true);
|
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",
|
gemini_local: "instructionsFilePath",
|
||||||
opencode_local: "instructionsFilePath",
|
opencode_local: "instructionsFilePath",
|
||||||
cursor: "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 KNOWN_INSTRUCTIONS_PATH_KEYS = new Set(["instructionsFilePath", "agentsMdPath"]);
|
||||||
|
|
||||||
const router = Router();
|
const router = Router();
|
||||||
@@ -328,6 +330,43 @@ export function agentRoutes(db: Db) {
|
|||||||
return path.resolve(cwd, trimmed);
|
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 }) {
|
async function assertCanManageInstructionsPath(req: Request, targetAgent: { id: string; companyId: string }) {
|
||||||
assertCompanyAccess(req, targetAgent.companyId);
|
assertCompanyAccess(req, targetAgent.companyId);
|
||||||
if (req.actor.type === "board") return;
|
if (req.actor.type === "board") return;
|
||||||
@@ -1035,12 +1074,13 @@ export function agentRoutes(db: Db) {
|
|||||||
|
|
||||||
const requiresApproval = company.requireBoardApprovalForNewAgents;
|
const requiresApproval = company.requireBoardApprovalForNewAgents;
|
||||||
const status = requiresApproval ? "pending_approval" : "idle";
|
const status = requiresApproval ? "pending_approval" : "idle";
|
||||||
const agent = await svc.create(companyId, {
|
const createdAgent = await svc.create(companyId, {
|
||||||
...normalizedHireInput,
|
...normalizedHireInput,
|
||||||
status,
|
status,
|
||||||
spentMonthlyCents: 0,
|
spentMonthlyCents: 0,
|
||||||
lastHeartbeatAt: null,
|
lastHeartbeatAt: null,
|
||||||
});
|
});
|
||||||
|
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent);
|
||||||
|
|
||||||
let approval: Awaited<ReturnType<typeof approvalsSvc.getById>> | null = null;
|
let approval: Awaited<ReturnType<typeof approvalsSvc.getById>> | null = null;
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
@@ -1049,7 +1089,7 @@ export function agentRoutes(db: Db) {
|
|||||||
const requestedAdapterType = normalizedHireInput.adapterType ?? agent.adapterType;
|
const requestedAdapterType = normalizedHireInput.adapterType ?? agent.adapterType;
|
||||||
const requestedAdapterConfig =
|
const requestedAdapterConfig =
|
||||||
redactEventPayload(
|
redactEventPayload(
|
||||||
(normalizedHireInput.adapterConfig ?? agent.adapterConfig) as Record<string, unknown>,
|
(agent.adapterConfig ?? normalizedHireInput.adapterConfig) as Record<string, unknown>,
|
||||||
) ?? {};
|
) ?? {};
|
||||||
const requestedRuntimeConfig =
|
const requestedRuntimeConfig =
|
||||||
redactEventPayload(
|
redactEventPayload(
|
||||||
@@ -1172,13 +1212,14 @@ export function agentRoutes(db: Db) {
|
|||||||
normalizedAdapterConfig,
|
normalizedAdapterConfig,
|
||||||
);
|
);
|
||||||
|
|
||||||
const agent = await svc.create(companyId, {
|
const createdAgent = await svc.create(companyId, {
|
||||||
...createInput,
|
...createInput,
|
||||||
adapterConfig: normalizedAdapterConfig,
|
adapterConfig: normalizedAdapterConfig,
|
||||||
status: "idle",
|
status: "idle",
|
||||||
spentMonthlyCents: 0,
|
spentMonthlyCents: 0,
|
||||||
lastHeartbeatAt: null,
|
lastHeartbeatAt: null,
|
||||||
});
|
});
|
||||||
|
const agent = await materializeDefaultInstructionsBundleForNewAgent(createdAgent);
|
||||||
|
|
||||||
const actor = getActorInfo(req);
|
const actor = getActorInfo(req);
|
||||||
await logActivity(db, {
|
await logActivity(db, {
|
||||||
|
|||||||
Reference in New Issue
Block a user