diff --git a/server/src/__tests__/agent-skills-routes.test.ts b/server/src/__tests__/agent-skills-routes.test.ts index f0b6c499..8c9101ae 100644 --- a/server/src/__tests__/agent-skills-routes.test.ts +++ b/server/src/__tests__/agent-skills-routes.test.ts @@ -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) => ({ + mockApprovalService.create.mockImplementation(async (_companyId: string, input: Record) => ({ id: "approval-1", companyId: "company-1", type: "hire_agent", status: "pending", payload: input.payload ?? {}, })); + mockAgentInstructionsService.materializeManagedBundle.mockImplementation( + async (agent: Record, files: Record) => ({ + bundle: null, + adapterConfig: { + ...((agent.adapterConfig as Record | 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 } } + | undefined; + expect(approvalInput?.payload?.adapterConfig?.promptTemplate).toBeUndefined(); + }); }); diff --git a/server/src/routes/agents.ts b/server/src/routes/agents.ts index 80bebc2d..9481840b 100644 --- a/server/src/routes/agents.ts +++ b/server/src/routes/agents.ts @@ -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(agent: T): Promise { + 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> | 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, + (agent.adapterConfig ?? normalizedHireInput.adapterConfig) as Record, ) ?? {}; 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, {